chore: initial Observable workspace with technical README
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
.observablehq/cache/
|
||||||
|
docs/.observablehq/
|
||||||
|
docs/.observablehq/cache/
|
||||||
|
.observablehq/
|
||||||
|
.cache/
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
15
.vscode/settings.json
vendored
Normal file
15
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"[markdown]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"editor.wordWrap": "on"
|
||||||
|
},
|
||||||
|
"[javascript]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
"files.associations": {
|
||||||
|
"*.md": "markdown",
|
||||||
|
"observablehq.config.js": "javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
137
README.md
Normal file
137
README.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# Observable Workspace — In-N-Out x Séismes x OVNIs (Californie)
|
||||||
|
|
||||||
|
Workspace **Observable Framework** orienté data storytelling avec deux analyses géospatiales en Californie :
|
||||||
|
|
||||||
|
- **In-N-Out Seismic Challenge** : classement des restaurants In‑N‑Out selon une exposition aux séismes.
|
||||||
|
- **Paradoxe de l’Animal Style** : corrélation exploratoire entre observations OVNI et proximité d’un In‑N‑Out.
|
||||||
|
|
||||||
|
## Objectifs
|
||||||
|
|
||||||
|
- Démontrer une chaîne complète de visualisation interactive avec Observable Framework.
|
||||||
|
- Croiser des données hétérogènes (OSM/Overpass, USGS, jeu OVNI) avec un traitement géospatial léger.
|
||||||
|
- Produire des pages analytiques reproductibles, versionnables et publiables statiquement.
|
||||||
|
|
||||||
|
## Stack technique
|
||||||
|
|
||||||
|
- **Runtime / Build** : `@observablehq/framework` (v1.x)
|
||||||
|
- **Langage** : JavaScript ESM (`"type": "module"`)
|
||||||
|
- **Parsing CSV** : `d3-dsv`
|
||||||
|
- **Visualisation** : `Plot` (via Observable), `d3`, `topojson-client`
|
||||||
|
- **Topologie US** : `us-atlas@3` (chargement CDN)
|
||||||
|
|
||||||
|
## Architecture du projet
|
||||||
|
|
||||||
|
```text
|
||||||
|
observablehq.config.js # Configuration Observable Framework
|
||||||
|
package.json # Scripts npm + dépendances
|
||||||
|
docs/
|
||||||
|
index.md # Page d'accueil
|
||||||
|
in-n-out-california.md # Analyse séismes ↔ In-N-Out
|
||||||
|
ufo-animal-style-paradox.md # Analyse OVNI ↔ In-N-Out
|
||||||
|
data/
|
||||||
|
in-n-out-ca-overpass.json.js
|
||||||
|
ca-earthquakes-last365d.json.js
|
||||||
|
in-n-out-ca-csv.json.js
|
||||||
|
ufo-ca-sightings-v2.json.js
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Observable
|
||||||
|
|
||||||
|
Le fichier `observablehq.config.js` définit :
|
||||||
|
|
||||||
|
- `root: "docs"` : toutes les pages et données vivent sous `docs/`
|
||||||
|
- `title: "Observable Workspace"`
|
||||||
|
- `pager: false`
|
||||||
|
- `toc: true`
|
||||||
|
- `theme: "dashboard"`
|
||||||
|
|
||||||
|
## Pipeline de données
|
||||||
|
|
||||||
|
### 1) In-N-Out (source primaire Overpass)
|
||||||
|
|
||||||
|
Le module `docs/data/in-n-out-ca-overpass.json.js` :
|
||||||
|
|
||||||
|
- interroge Overpass API pour les POI In‑N‑Out (`node/way/relation`),
|
||||||
|
- normalise les attributs (`id`, `latitude`, `longitude`, `name`, `city`, `address`, `postcode`),
|
||||||
|
- déduplique par coordonnée arrondie,
|
||||||
|
- tente plusieurs endpoints Overpass (fallback de résilience),
|
||||||
|
- bascule sur un fallback CSV GitHub en cas d’échec.
|
||||||
|
|
||||||
|
### 2) Séismes Californie
|
||||||
|
|
||||||
|
Le dataset `ca-earthquakes-last365d.json` est utilisé dans la page `in-n-out-california.md` pour :
|
||||||
|
|
||||||
|
- filtrer les séismes dans une bounding box Californie,
|
||||||
|
- calculer la distance haversine de chaque séisme à chaque magasin,
|
||||||
|
- agréger des métriques locales (`quakeCount`, `maxMagnitude`, `avgMagnitude`, `nearestKm`),
|
||||||
|
- produire un **Shake Index** composite.
|
||||||
|
|
||||||
|
Formule utilisée :
|
||||||
|
|
||||||
|
`shakeIndex = quakeCount * 1.5 + maxMagnitude * 12 + max(0, radiusKm - min(nearestKm, radiusKm)) * 0.25`
|
||||||
|
|
||||||
|
### 3) OVNI Californie
|
||||||
|
|
||||||
|
La page `ufo-animal-style-paradox.md` :
|
||||||
|
|
||||||
|
- charge les observations OVNI et les points In‑N‑Out,
|
||||||
|
- calcule le magasin le plus proche pour chaque observation (distance haversine),
|
||||||
|
- marque `closeToBurger` selon un rayon interactif,
|
||||||
|
- agrège des hotspots via regroupement par magasin le plus proche,
|
||||||
|
- expose des KPIs interactifs (taux de proximité, volume, durée moyenne, etc.).
|
||||||
|
|
||||||
|
## Visualisations
|
||||||
|
|
||||||
|
- Bar charts classés (top/bottom stores)
|
||||||
|
- Carte Californie (projection Albers)
|
||||||
|
- Nuages de points et outils de filtrage (`Inputs.select`, `Inputs.range`)
|
||||||
|
- Tooltips détaillés pour exploration ad hoc
|
||||||
|
|
||||||
|
## Exécution locale
|
||||||
|
|
||||||
|
Pré-requis :
|
||||||
|
|
||||||
|
- Node.js 18+ recommandé
|
||||||
|
- npm
|
||||||
|
|
||||||
|
Installation :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Développement (live preview) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Build statique :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sorties et dossiers générés
|
||||||
|
|
||||||
|
- `dist/` : artefacts de build statique (à ne pas versionner)
|
||||||
|
- `docs/.observablehq/` : cache/framework interne (à ne pas versionner)
|
||||||
|
|
||||||
|
Le `.gitignore` est configuré pour ignorer ces dossiers de cache/génération.
|
||||||
|
|
||||||
|
## Qualité, limites et reproductibilité
|
||||||
|
|
||||||
|
- Les données externes (Overpass, CDN, sources tierces) peuvent varier dans le temps.
|
||||||
|
- Les métriques proposées sont exploratoires et non causales.
|
||||||
|
- Le pipeline privilégie robustesse et disponibilité (fallback réseau) plutôt qu’une ETL lourde.
|
||||||
|
|
||||||
|
## Scripts npm
|
||||||
|
|
||||||
|
- `npm run dev` → `observable preview`
|
||||||
|
- `npm run build` → `observable build`
|
||||||
|
|
||||||
|
## Publication Git
|
||||||
|
|
||||||
|
Ce dépôt est prévu pour être poussé sur un remote Gitea.
|
||||||
|
Le README sert de documentation technique de référence pour maintenance, reprise et extension du projet.
|
||||||
6
docs/components/hello.js
Normal file
6
docs/components/hello.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export function helloCard(text) {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "card";
|
||||||
|
el.textContent = text;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
47
docs/data/ca-earthquakes-last365d.json.js
Normal file
47
docs/data/ca-earthquakes-last365d.json.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
const now = new Date();
|
||||||
|
const start = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
format: "geojson",
|
||||||
|
starttime: start.toISOString(),
|
||||||
|
endtime: now.toISOString(),
|
||||||
|
minmagnitude: "2.5",
|
||||||
|
minlatitude: "32",
|
||||||
|
maxlatitude: "42.5",
|
||||||
|
minlongitude: "-125",
|
||||||
|
maxlongitude: "-114",
|
||||||
|
orderby: "time-asc",
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = `https://earthquake.usgs.gov/fdsnws/event/1/query?${params}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`USGS request failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
const features = Array.isArray(payload.features) ? payload.features : [];
|
||||||
|
|
||||||
|
const quakes = features
|
||||||
|
.map((feature) => {
|
||||||
|
const [longitude, latitude] = feature.geometry?.coordinates ?? [];
|
||||||
|
const mag = Number(feature.properties?.mag);
|
||||||
|
if (
|
||||||
|
!Number.isFinite(latitude) ||
|
||||||
|
!Number.isFinite(longitude) ||
|
||||||
|
!Number.isFinite(mag)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: feature.id,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
mag,
|
||||||
|
place: feature.properties?.place ?? "",
|
||||||
|
time: feature.properties?.time ?? null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
process.stdout.write(JSON.stringify(quakes));
|
||||||
41
docs/data/in-n-out-ca-csv.json.js
Normal file
41
docs/data/in-n-out-ca-csv.json.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
const csvUrl =
|
||||||
|
"https://raw.githubusercontent.com/madmao/castle-compass/master/csv/In%20N%20Out.csv";
|
||||||
|
const response = await fetch(csvUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`In-N-Out CSV request failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = (await response.text()).split(/\r?\n/).filter(Boolean);
|
||||||
|
|
||||||
|
const stores = rows
|
||||||
|
.map((line, index) => {
|
||||||
|
const [latRaw, lonRaw] = line.split(",");
|
||||||
|
const latitude = Number(latRaw);
|
||||||
|
const longitude = Number(lonRaw);
|
||||||
|
|
||||||
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
||||||
|
|
||||||
|
const inCaliforniaBounds =
|
||||||
|
latitude >= 32 &&
|
||||||
|
latitude <= 42.5 &&
|
||||||
|
longitude >= -125 &&
|
||||||
|
longitude <= -114;
|
||||||
|
|
||||||
|
if (!inCaliforniaBounds) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `ino/${index}`,
|
||||||
|
name: "In-N-Out Burger",
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const dedup = new Map();
|
||||||
|
for (const store of stores) {
|
||||||
|
const key = `${store.latitude.toFixed(5)},${store.longitude.toFixed(5)}`;
|
||||||
|
if (!dedup.has(key)) dedup.set(key, store);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(JSON.stringify(Array.from(dedup.values())));
|
||||||
118
docs/data/in-n-out-ca-overpass.json.js
Normal file
118
docs/data/in-n-out-ca-overpass.json.js
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
const query = `
|
||||||
|
[out:json][timeout:120];
|
||||||
|
area["ISO3166-2"="US-CA"][admin_level=4]->.searchArea;
|
||||||
|
(
|
||||||
|
node["amenity"="fast_food"]["brand"="In-N-Out Burger"](area.searchArea);
|
||||||
|
way["amenity"="fast_food"]["brand"="In-N-Out Burger"](area.searchArea);
|
||||||
|
relation["amenity"="fast_food"]["brand"="In-N-Out Burger"](area.searchArea);
|
||||||
|
node["amenity"="fast_food"]["name"~"In-N-Out",i](area.searchArea);
|
||||||
|
way["amenity"="fast_food"]["name"~"In-N-Out",i](area.searchArea);
|
||||||
|
relation["amenity"="fast_food"]["name"~"In-N-Out",i](area.searchArea);
|
||||||
|
);
|
||||||
|
out center tags;
|
||||||
|
`;
|
||||||
|
|
||||||
|
function normalizeStores(elements) {
|
||||||
|
const stores = elements
|
||||||
|
.map((element) => {
|
||||||
|
const lat = Number(element.lat ?? element.center?.lat);
|
||||||
|
const lon = Number(element.lon ?? element.center?.lon);
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||||
|
|
||||||
|
const tags = element.tags ?? {};
|
||||||
|
const name = tags.name || "In-N-Out Burger";
|
||||||
|
const city = tags["addr:city"] || "Ville inconnue";
|
||||||
|
const street = tags["addr:street"] || "";
|
||||||
|
const housenumber = tags["addr:housenumber"] || "";
|
||||||
|
const postcode = tags["addr:postcode"] || "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `${element.type}/${element.id}`,
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lon,
|
||||||
|
name,
|
||||||
|
city,
|
||||||
|
address: [housenumber, street].filter(Boolean).join(" "),
|
||||||
|
postcode,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const dedup = new Map();
|
||||||
|
for (const store of stores) {
|
||||||
|
const key = `${store.latitude.toFixed(5)},${store.longitude.toFixed(5)}`;
|
||||||
|
if (!dedup.has(key)) dedup.set(key, store);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(dedup.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFromOverpass() {
|
||||||
|
const endpoints = [
|
||||||
|
"https://overpass-api.de/api/interpreter",
|
||||||
|
"https://overpass.kumi.systems/api/interpreter",
|
||||||
|
"https://overpass.openstreetmap.fr/api/interpreter",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const endpoint of endpoints) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/x-www-form-urlencoded;charset=UTF-8",
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({ data: query }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) continue;
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
const elements = Array.isArray(payload.elements) ? payload.elements : [];
|
||||||
|
const stores = normalizeStores(elements);
|
||||||
|
if (stores.length >= 50) return stores;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFallbackCsv() {
|
||||||
|
const url =
|
||||||
|
"https://raw.githubusercontent.com/madmao/castle-compass/master/csv/In%20N%20Out.csv";
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(`Fallback CSV request failed: ${response.status}`);
|
||||||
|
|
||||||
|
const lines = (await response.text()).split(/\r?\n/).filter(Boolean);
|
||||||
|
const stores = lines
|
||||||
|
.map((line, index) => {
|
||||||
|
const [latRaw, lonRaw] = line.split(",");
|
||||||
|
const latitude = Number(latRaw);
|
||||||
|
const longitude = Number(lonRaw);
|
||||||
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
const inCaliforniaBounds =
|
||||||
|
latitude >= 32 &&
|
||||||
|
latitude <= 42.5 &&
|
||||||
|
longitude >= -125 &&
|
||||||
|
longitude <= -114;
|
||||||
|
if (!inCaliforniaBounds) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `fallback/${index}`,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
name: "In-N-Out Burger",
|
||||||
|
city: "Ville inconnue",
|
||||||
|
address: "",
|
||||||
|
postcode: "",
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return stores;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stores = (await fetchFromOverpass()) ?? (await fetchFallbackCsv());
|
||||||
|
process.stdout.write(JSON.stringify(stores));
|
||||||
8
docs/data/sample.json.js
Normal file
8
docs/data/sample.json.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
process.stdout.write(
|
||||||
|
JSON.stringify({
|
||||||
|
message: "Sample data loader OK",
|
||||||
|
generatedAt: now,
|
||||||
|
}),
|
||||||
|
);
|
||||||
83
docs/data/ufo-ca-sightings-v2.json.js
Normal file
83
docs/data/ufo-ca-sightings-v2.json.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
const candidates = [
|
||||||
|
"https://raw.githubusercontent.com/richaude/blue-book/master/scrubbed.csv",
|
||||||
|
"https://raw.githubusercontent.com/alecfirrincieli/UFO-sightings/main/ufo-sightings.csv",
|
||||||
|
];
|
||||||
|
|
||||||
|
let csvText = null;
|
||||||
|
for (const url of candidates) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) continue;
|
||||||
|
const text = await response.text();
|
||||||
|
if (text && text.includes("datetime,city,state")) {
|
||||||
|
csvText = text;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!csvText) {
|
||||||
|
throw new Error("Unable to fetch UFO sightings dataset from public mirrors.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { csvParse } = await import("d3-dsv");
|
||||||
|
const rows = csvParse(csvText);
|
||||||
|
|
||||||
|
const normalizeKey = (value) =>
|
||||||
|
String(value ?? "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
function readField(row, key) {
|
||||||
|
const target = normalizeKey(key);
|
||||||
|
for (const [field, value] of Object.entries(row)) {
|
||||||
|
if (normalizeKey(field) === target) return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumber(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ufo = rows
|
||||||
|
.map((row, index) => {
|
||||||
|
const state = String(readField(row, "state") ?? "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const country = String(readField(row, "country") ?? "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const latitude = parseNumber(readField(row, "latitude"));
|
||||||
|
const longitude = parseNumber(readField(row, "longitude"));
|
||||||
|
|
||||||
|
if (state !== "ca" || country !== "us") return null;
|
||||||
|
if (latitude === null || longitude === null) return null;
|
||||||
|
|
||||||
|
const inCaliforniaBounds =
|
||||||
|
latitude >= 32 &&
|
||||||
|
latitude <= 42.5 &&
|
||||||
|
longitude >= -125 &&
|
||||||
|
longitude <= -114;
|
||||||
|
|
||||||
|
if (!inCaliforniaBounds) return null;
|
||||||
|
|
||||||
|
const durationSeconds =
|
||||||
|
parseNumber(readField(row, "duration (seconds)")) ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `ufo/${index}`,
|
||||||
|
datetime: readField(row, "datetime") ?? null,
|
||||||
|
city: readField(row, "city") ?? "Unknown",
|
||||||
|
state,
|
||||||
|
shape: String(readField(row, "shape") ?? "unknown").toLowerCase(),
|
||||||
|
durationSeconds,
|
||||||
|
comments: readField(row, "comments") ?? "",
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
process.stdout.write(JSON.stringify(ufo));
|
||||||
83
docs/data/ufo-ca-sightings.json.js
Normal file
83
docs/data/ufo-ca-sightings.json.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
const candidates = [
|
||||||
|
"https://raw.githubusercontent.com/richaude/blue-book/master/scrubbed.csv",
|
||||||
|
"https://raw.githubusercontent.com/alecfirrincieli/UFO-sightings/main/ufo-sightings.csv",
|
||||||
|
];
|
||||||
|
|
||||||
|
let csvText = null;
|
||||||
|
for (const url of candidates) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) continue;
|
||||||
|
const text = await response.text();
|
||||||
|
if (text && text.includes("datetime,city,state")) {
|
||||||
|
csvText = text;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!csvText) {
|
||||||
|
throw new Error("Unable to fetch UFO sightings dataset from public mirrors.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { csvParse } = await import("d3-dsv");
|
||||||
|
const rows = csvParse(csvText);
|
||||||
|
|
||||||
|
const normalizeKey = (value) =>
|
||||||
|
String(value ?? "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
function readField(row, key) {
|
||||||
|
const target = normalizeKey(key);
|
||||||
|
for (const [field, value] of Object.entries(row)) {
|
||||||
|
if (normalizeKey(field) === target) return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumber(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ufo = rows
|
||||||
|
.map((row, index) => {
|
||||||
|
const state = String(readField(row, "state") ?? "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const country = String(readField(row, "country") ?? "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const latitude = parseNumber(readField(row, "latitude"));
|
||||||
|
const longitude = parseNumber(readField(row, "longitude"));
|
||||||
|
|
||||||
|
if (state !== "ca" || country !== "us") return null;
|
||||||
|
if (latitude === null || longitude === null) return null;
|
||||||
|
|
||||||
|
const inCaliforniaBounds =
|
||||||
|
latitude >= 32 &&
|
||||||
|
latitude <= 42.5 &&
|
||||||
|
longitude >= -125 &&
|
||||||
|
longitude <= -114;
|
||||||
|
|
||||||
|
if (!inCaliforniaBounds) return null;
|
||||||
|
|
||||||
|
const durationSeconds =
|
||||||
|
parseNumber(readField(row, "duration (seconds)")) ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `ufo/${index}`,
|
||||||
|
datetime: readField(row, "datetime") ?? null,
|
||||||
|
city: readField(row, "city") ?? "Unknown",
|
||||||
|
state,
|
||||||
|
shape: String(readField(row, "shape") ?? "unknown").toLowerCase(),
|
||||||
|
durationSeconds,
|
||||||
|
comments: readField(row, "comments") ?? "",
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
process.stdout.write(JSON.stringify(ufo));
|
||||||
214
docs/in-n-out-california.md
Normal file
214
docs/in-n-out-california.md
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
---
|
||||||
|
title: In-N-Out Seismic Challenge (CA)
|
||||||
|
toc: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quel In-N-Out de Californie est le plus « secoué » ? 🌋🍔
|
||||||
|
|
||||||
|
Problématique zinzin :
|
||||||
|
**si tu veux manger un burger sans mini-frisson tectonique, quel In-N-Out faut-il éviter ?**
|
||||||
|
|
||||||
|
Datasets utilisés :
|
||||||
|
|
||||||
|
- `docs/data/in-n-out-ca-overpass.json.js` (OpenStreetMap / Overpass, avec ville et adresse)
|
||||||
|
- `docs/data/ca-earthquakes-last365d.json.js` (USGS, séismes M≥2.5 sur 365 jours)
|
||||||
|
|
||||||
|
```js
|
||||||
|
const [stores, quakes] = await Promise.all([
|
||||||
|
FileAttachment("./data/in-n-out-ca-overpass.json").json(),
|
||||||
|
FileAttachment("./data/ca-earthquakes-last365d.json").json(),
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
const [d3, topojson] = await Promise.all([
|
||||||
|
import("https://cdn.jsdelivr.net/npm/d3@7/+esm"),
|
||||||
|
import("https://cdn.jsdelivr.net/npm/topojson-client@3/+esm"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const usAtlasUrl = "https://cdn.jsdelivr.net/npm/us-atlas@3/states-10m.json";
|
||||||
|
|
||||||
|
const us = await fetch(usAtlasUrl).then((r) => r.json());
|
||||||
|
|
||||||
|
const states = topojson.feature(us, us.objects.states);
|
||||||
|
const california = states.features.find((s) => Number(s.id) === 6);
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
function haversineKm(lat1, lon1, lat2, lon2) {
|
||||||
|
const toRad = (deg) => (deg * Math.PI) / 180;
|
||||||
|
const R = 6371;
|
||||||
|
const dLat = toRad(lat2 - lat1);
|
||||||
|
const dLon = toRad(lon2 - lon1);
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||||
|
return 2 * R * Math.asin(Math.sqrt(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
const californiaStores = stores.filter((store) =>
|
||||||
|
d3.geoContains(california, [store.longitude, store.latitude]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const quakesInCaBox = quakes.filter(
|
||||||
|
(quake) =>
|
||||||
|
quake.latitude >= 32 &&
|
||||||
|
quake.latitude <= 42.5 &&
|
||||||
|
quake.longitude >= -125 &&
|
||||||
|
quake.longitude <= -114,
|
||||||
|
);
|
||||||
|
|
||||||
|
const radiusKm = 75;
|
||||||
|
|
||||||
|
const shakeStats = californiaStores.map((store) => {
|
||||||
|
const distances = quakesInCaBox.map((quake) => {
|
||||||
|
const distanceKm = haversineKm(
|
||||||
|
store.latitude,
|
||||||
|
store.longitude,
|
||||||
|
quake.latitude,
|
||||||
|
quake.longitude,
|
||||||
|
);
|
||||||
|
return { quake, distanceKm };
|
||||||
|
});
|
||||||
|
|
||||||
|
const nearest = d3.least(distances, (d) => d.distanceKm);
|
||||||
|
const nearby = distances.filter((d) => d.distanceKm <= radiusKm);
|
||||||
|
|
||||||
|
const quakeCount = nearby.length;
|
||||||
|
const maxMagnitude = d3.max(nearby, (d) => d.quake.mag) ?? 0;
|
||||||
|
const avgMagnitude = d3.mean(nearby, (d) => d.quake.mag) ?? 0;
|
||||||
|
const nearestKm = nearest?.distanceKm ?? Infinity;
|
||||||
|
|
||||||
|
const shakeIndex =
|
||||||
|
quakeCount * 1.5 +
|
||||||
|
maxMagnitude * 12 +
|
||||||
|
Math.max(0, radiusKm - Math.min(nearestKm, radiusKm)) * 0.25;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...store,
|
||||||
|
quakeCount,
|
||||||
|
maxMagnitude,
|
||||||
|
avgMagnitude,
|
||||||
|
nearestKm,
|
||||||
|
shakeIndex,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const ranked = d3.sort(shakeStats, (d) => -d.shakeIndex);
|
||||||
|
const top20 = ranked.slice(0, 20);
|
||||||
|
const safest20 = d3.sort(shakeStats, (d) => d.shakeIndex).slice(0, 20);
|
||||||
|
const boss = ranked[0];
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
display(html`
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-top:0">🏆 In-N-Out le plus « secoué »</h2>
|
||||||
|
<p style="font-size:1.05rem;margin:0.4rem 0 0.2rem 0;">
|
||||||
|
<strong>${boss?.name ?? "N/A"}</strong> —
|
||||||
|
${boss?.city ?? "Ville inconnue"}
|
||||||
|
</p>
|
||||||
|
<p style="margin:0.1rem 0;">
|
||||||
|
Shake Index: <strong>${boss ? boss.shakeIndex.toFixed(1) : "N/A"}</strong>
|
||||||
|
</p>
|
||||||
|
<p style="margin:0.1rem 0;">
|
||||||
|
Séismes à ≤ ${radiusKm} km: <strong>${boss?.quakeCount ?? 0}</strong> ·
|
||||||
|
Magnitude max:
|
||||||
|
<strong>${boss ? boss.maxMagnitude.toFixed(1) : "0.0"}</strong> · Plus
|
||||||
|
proche:
|
||||||
|
<strong
|
||||||
|
>${boss && Number.isFinite(boss.nearestKm)
|
||||||
|
? boss.nearestKm.toFixed(1)
|
||||||
|
: "∞"}
|
||||||
|
km</strong
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
display(
|
||||||
|
Plot.plot({
|
||||||
|
width,
|
||||||
|
height: 480,
|
||||||
|
marginLeft: 280,
|
||||||
|
x: { label: "Shake Index", grid: true },
|
||||||
|
y: { label: null },
|
||||||
|
marks: [
|
||||||
|
Plot.barX(top20, {
|
||||||
|
x: "shakeIndex",
|
||||||
|
y: (d) => `${d.name} (${d.city})`,
|
||||||
|
sort: { y: "x", reverse: true },
|
||||||
|
fill: "currentColor",
|
||||||
|
fillOpacity: 0.75,
|
||||||
|
tip: true,
|
||||||
|
title: (d) =>
|
||||||
|
`${d.name} — ${d.city}\nShake Index: ${d.shakeIndex.toFixed(1)}\nSéismes ≤ ${radiusKm} km: ${d.quakeCount}\nMagnitude max: ${d.maxMagnitude.toFixed(2)}\nSéisme le plus proche: ${Number.isFinite(d.nearestKm) ? d.nearestKm.toFixed(1) : "∞"} km`,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
const map = Plot.plot({
|
||||||
|
width,
|
||||||
|
height: 620,
|
||||||
|
projection: { type: "albers", domain: california },
|
||||||
|
color: {
|
||||||
|
label: "Shake Index",
|
||||||
|
scheme: "oranges",
|
||||||
|
},
|
||||||
|
marks: [
|
||||||
|
Plot.geo(california, {
|
||||||
|
fill: "currentColor",
|
||||||
|
fillOpacity: 0.06,
|
||||||
|
stroke: "currentColor",
|
||||||
|
}),
|
||||||
|
Plot.dot(shakeStats, {
|
||||||
|
x: "longitude",
|
||||||
|
y: "latitude",
|
||||||
|
r: (d) => 2 + Math.min(10, d.quakeCount / 5),
|
||||||
|
fill: "shakeIndex",
|
||||||
|
fillOpacity: 0.85,
|
||||||
|
stroke: "currentColor",
|
||||||
|
strokeOpacity: 0.25,
|
||||||
|
tip: true,
|
||||||
|
title: (d) =>
|
||||||
|
`${d.name} — ${d.city}\nShake Index: ${d.shakeIndex.toFixed(1)}\nSéismes ≤ ${radiusKm} km: ${d.quakeCount}\nMagnitude max: ${d.maxMagnitude.toFixed(2)}\nSéisme le plus proche: ${Number.isFinite(d.nearestKm) ? d.nearestKm.toFixed(1) : "∞"} km`,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
display(map);
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
display(html`
|
||||||
|
<div class="grid grid-cols-2">
|
||||||
|
<div class="card">
|
||||||
|
<h3 style="margin-top:0">🔥 Top 5 des plus secoués</h3>
|
||||||
|
<ol style="margin:0;padding-left:1.2rem;">
|
||||||
|
${ranked
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(
|
||||||
|
(d) =>
|
||||||
|
html`<li>${d.name} (${d.city}) — ${d.shakeIndex.toFixed(1)}</li>`,
|
||||||
|
)}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3 style="margin-top:0">🧘 Top 5 des plus chill</h3>
|
||||||
|
<ol style="margin:0;padding-left:1.2rem;">
|
||||||
|
${safest20
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(
|
||||||
|
(d) =>
|
||||||
|
html`<li>${d.name} (${d.city}) — ${d.shakeIndex.toFixed(1)}</li>`,
|
||||||
|
)}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
```
|
||||||
15
docs/index.md
Normal file
15
docs/index.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
title: Accueil
|
||||||
|
toc: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bonjour Observable Framework
|
||||||
|
|
||||||
|
Votre workspace est prêt. Modifiez ce fichier et enregistrez pour voir le live reload.
|
||||||
|
|
||||||
|
- 👉 [Carte In-N-Out en Californie](./in-n-out-california)
|
||||||
|
- 🛸 [Paradoxe de l’Animal Style (OVNI x In-N-Out)](./ufo-animal-style-paradox)
|
||||||
|
|
||||||
|
```js
|
||||||
|
display(`Date: ${new Date().toLocaleString()}`);
|
||||||
|
```
|
||||||
339
docs/ufo-animal-style-paradox.md
Normal file
339
docs/ufo-animal-style-paradox.md
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
---
|
||||||
|
title: Paradoxe de l’Animal Style
|
||||||
|
toc: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🛸 Le Paradoxe de l’Animal Style
|
||||||
|
|
||||||
|
> **Question absurde mais testable :**
|
||||||
|
> Les OVNIs en Californie survolent-ils plus souvent des zones proches des In-N-Out ?
|
||||||
|
|
||||||
|
```js
|
||||||
|
display(html`
|
||||||
|
<style>
|
||||||
|
.alien-board {
|
||||||
|
background: #0b0e14;
|
||||||
|
border: 1px solid #1c2333;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
color: #d8e1ff;
|
||||||
|
}
|
||||||
|
.alien-kpi {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.alien-kpi .card {
|
||||||
|
background: #111726;
|
||||||
|
border: 1px solid #1d2940;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.7rem;
|
||||||
|
color: #d8e1ff;
|
||||||
|
}
|
||||||
|
.alien-kpi .value {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #39ff14;
|
||||||
|
}
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.alien-kpi {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
`);
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
const [inNOut, ufoSightings] = await Promise.all([
|
||||||
|
FileAttachment("./data/in-n-out-ca-csv.json").json(),
|
||||||
|
FileAttachment("./data/ufo-ca-sightings-v2.json").json(),
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
const [d3, topojson] = await Promise.all([
|
||||||
|
import("https://cdn.jsdelivr.net/npm/d3@7/+esm"),
|
||||||
|
import("https://cdn.jsdelivr.net/npm/topojson-client@3/+esm"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const usAtlasUrl = "https://cdn.jsdelivr.net/npm/us-atlas@3/states-10m.json";
|
||||||
|
const us = await fetch(usAtlasUrl).then((r) => r.json());
|
||||||
|
const states = topojson.feature(us, us.objects.states);
|
||||||
|
const california = states.features.find((s) => Number(s.id) === 6);
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
function haversineKm(lat1, lon1, lat2, lon2) {
|
||||||
|
const toRad = (deg) => (deg * Math.PI) / 180;
|
||||||
|
const R = 6371;
|
||||||
|
const dLat = toRad(lat2 - lat1);
|
||||||
|
const dLon = toRad(lon2 - lon1);
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||||
|
return 2 * R * Math.asin(Math.sqrt(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestStore(ufo, stores) {
|
||||||
|
let bestStore = null;
|
||||||
|
let bestDistance = Infinity;
|
||||||
|
|
||||||
|
for (const store of stores) {
|
||||||
|
const distance = haversineKm(
|
||||||
|
ufo.latitude,
|
||||||
|
ufo.longitude,
|
||||||
|
store.latitude,
|
||||||
|
store.longitude,
|
||||||
|
);
|
||||||
|
if (distance < bestDistance) {
|
||||||
|
bestDistance = distance;
|
||||||
|
bestStore = store;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { store: bestStore, distanceKm: bestDistance };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
const shapes = [
|
||||||
|
"all",
|
||||||
|
...d3
|
||||||
|
.sort(Array.from(new Set(ufoSightings.map((d) => d.shape || "unknown"))))
|
||||||
|
.slice(0, 20),
|
||||||
|
];
|
||||||
|
|
||||||
|
const selectedShape = view(
|
||||||
|
Inputs.select(shapes, {
|
||||||
|
label: "Filtre de forme OVNI",
|
||||||
|
value: "all",
|
||||||
|
format: (d) => (d === "all" ? "Toutes les formes" : d),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const hungerRadiusKm = view(
|
||||||
|
Inputs.range([5, 80], {
|
||||||
|
label: "Curseur de fringale extraterrestre (km)",
|
||||||
|
value: 24,
|
||||||
|
step: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const hexbinWidth = view(
|
||||||
|
Inputs.range([8, 28], {
|
||||||
|
label: "Largeur des hexagones radar",
|
||||||
|
value: 14,
|
||||||
|
step: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
const filteredUfo = ufoSightings.filter((ufo) =>
|
||||||
|
selectedShape === "all" ? true : ufo.shape === selectedShape,
|
||||||
|
);
|
||||||
|
|
||||||
|
const enrichedUfo = filteredUfo.map((ufo) => {
|
||||||
|
const nearest = nearestStore(ufo, inNOut);
|
||||||
|
return {
|
||||||
|
...ufo,
|
||||||
|
nearestStoreId: nearest.store?.id ?? null,
|
||||||
|
nearestStoreLat: nearest.store?.latitude ?? null,
|
||||||
|
nearestStoreLon: nearest.store?.longitude ?? null,
|
||||||
|
nearestDistanceKm: nearest.distanceKm,
|
||||||
|
closeToBurger: nearest.distanceKm <= hungerRadiusKm,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const hungerRate =
|
||||||
|
enrichedUfo.length > 0
|
||||||
|
? (enrichedUfo.filter((d) => d.closeToBurger).length / enrichedUfo.length) *
|
||||||
|
100
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const byStore = d3.rollups(
|
||||||
|
enrichedUfo,
|
||||||
|
(rows) => ({
|
||||||
|
sightings: rows.length,
|
||||||
|
avgDuration: d3.mean(rows, (d) => d.durationSeconds) ?? 0,
|
||||||
|
medDistance: d3.median(rows, (d) => d.nearestDistanceKm) ?? 0,
|
||||||
|
}),
|
||||||
|
(d) => d.nearestStoreId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const storeHotspots = byStore
|
||||||
|
.map(([storeId, stats]) => {
|
||||||
|
const store = inNOut.find((d) => d.id === storeId);
|
||||||
|
if (!store) return null;
|
||||||
|
return {
|
||||||
|
...store,
|
||||||
|
sightings: stats.sightings,
|
||||||
|
avgDuration: stats.avgDuration,
|
||||||
|
medDistance: stats.medDistance,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => b.sightings - a.sightings);
|
||||||
|
|
||||||
|
const topHotspots = storeHotspots.slice(0, 12);
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
display(html`
|
||||||
|
<div class="alien-board">
|
||||||
|
<div style="font-size:1.05rem;line-height:1.4;">
|
||||||
|
<strong>Dossier classifié:</strong> “Les zones de survol OVNI sont-elles
|
||||||
|
alignées avec les territoires In-N-Out ?”
|
||||||
|
</div>
|
||||||
|
<div class="alien-kpi">
|
||||||
|
<div class="card">
|
||||||
|
<div>Apparitions OVNI (filtre actif)</div>
|
||||||
|
<div class="value">${enrichedUfo.length.toLocaleString("fr-FR")}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div>In-N-Out en Californie</div>
|
||||||
|
<div class="value">${inNOut.length.toLocaleString("fr-FR")}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div>Taux de faim extraterrestre</div>
|
||||||
|
<div class="value">${hungerRate.toFixed(1)}%</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div>Rayon de corrélation</div>
|
||||||
|
<div class="value">${hungerRadiusKm} km</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
const map = Plot.plot({
|
||||||
|
width,
|
||||||
|
height: 700,
|
||||||
|
projection: { type: "albers", domain: california },
|
||||||
|
style: { background: "#0f0f0f", color: "#d8e1ff" },
|
||||||
|
color: {
|
||||||
|
label: "Proximité burger",
|
||||||
|
domain: ["≤ rayon", "> rayon"],
|
||||||
|
range: ["#39FF14", "#00F3FF"],
|
||||||
|
},
|
||||||
|
marks: [
|
||||||
|
Plot.geo(california, {
|
||||||
|
fill: "#0f0f0f",
|
||||||
|
stroke: "#303847",
|
||||||
|
strokeWidth: 0.8,
|
||||||
|
}),
|
||||||
|
Plot.dot(enrichedUfo, {
|
||||||
|
x: "longitude",
|
||||||
|
y: "latitude",
|
||||||
|
r: (d) => (d.closeToBurger ? 2.4 : 1.8),
|
||||||
|
fill: (d) => (d.closeToBurger ? "≤ rayon" : "> rayon"),
|
||||||
|
fillOpacity: 0.38,
|
||||||
|
stroke: "#0f0f0f",
|
||||||
|
strokeOpacity: 0.15,
|
||||||
|
tip: true,
|
||||||
|
title: (d) =>
|
||||||
|
`OVNI — ${d.city}\nForme: ${d.shape}\nDistance In-N-Out: ${d.nearestDistanceKm.toFixed(2)} km`,
|
||||||
|
}),
|
||||||
|
Plot.dot(inNOut, {
|
||||||
|
x: "longitude",
|
||||||
|
y: "latitude",
|
||||||
|
fill: "#E31837",
|
||||||
|
stroke: "#FFFFFF",
|
||||||
|
strokeOpacity: 0.45,
|
||||||
|
strokeWidth: 0.5,
|
||||||
|
symbol: "star",
|
||||||
|
r: 2.8,
|
||||||
|
tip: true,
|
||||||
|
title: (d) =>
|
||||||
|
`${d.name}\nLat: ${d.latitude.toFixed(3)}\nLon: ${d.longitude.toFixed(3)}`,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
display(map);
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
const scatter = Plot.plot({
|
||||||
|
width,
|
||||||
|
height: 420,
|
||||||
|
style: { background: "#0f0f0f", color: "#d8e1ff" },
|
||||||
|
x: { label: "Distance au In-N-Out le plus proche (km)", grid: true },
|
||||||
|
y: { label: "Durée observation OVNI (secondes)", type: "log", grid: true },
|
||||||
|
color: {
|
||||||
|
legend: true,
|
||||||
|
range: ["#00F3FF", "#39FF14"],
|
||||||
|
},
|
||||||
|
marks: [
|
||||||
|
Plot.ruleX([hungerRadiusKm], { stroke: "#FFCC00", strokeDasharray: "4,4" }),
|
||||||
|
Plot.dot(enrichedUfo, {
|
||||||
|
x: "nearestDistanceKm",
|
||||||
|
y: (d) => Math.max(1, d.durationSeconds),
|
||||||
|
fill: (d) => (d.closeToBurger ? "≤ rayon" : "> rayon"),
|
||||||
|
r: 2.4,
|
||||||
|
fillOpacity: 0.65,
|
||||||
|
tip: true,
|
||||||
|
title: (d) =>
|
||||||
|
`Ville: ${d.city}\nForme: ${d.shape}\nDistance In-N-Out: ${d.nearestDistanceKm.toFixed(2)} km\nDurée: ${d.durationSeconds}s`,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
display(scatter);
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
display(
|
||||||
|
Plot.plot({
|
||||||
|
width,
|
||||||
|
height: 420,
|
||||||
|
marginLeft: 260,
|
||||||
|
style: { background: "#0f0f0f", color: "#d8e1ff" },
|
||||||
|
x: { label: "Apparitions OVNI (cellule de Voronoï)", grid: true },
|
||||||
|
y: { label: null },
|
||||||
|
marks: [
|
||||||
|
Plot.barX(topHotspots, {
|
||||||
|
x: "sightings",
|
||||||
|
y: (d) =>
|
||||||
|
`${d.name} (${d.latitude.toFixed(2)}, ${d.longitude.toFixed(2)})`,
|
||||||
|
sort: { y: "x", reverse: true },
|
||||||
|
fill: "#E31837",
|
||||||
|
fillOpacity: 0.85,
|
||||||
|
tip: true,
|
||||||
|
title: (d) =>
|
||||||
|
`In-N-Out hotspot\nApparitions OVNI: ${d.sightings}\nDistance médiane: ${d.medDistance.toFixed(2)} km\nDurée moyenne: ${Math.round(d.avgDuration)} s`,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
const conclusion =
|
||||||
|
hungerRate >= 75
|
||||||
|
? "👽 Corrélation CRITIQUE : les aliens semblent avoir le menu secret."
|
||||||
|
: hungerRate >= 50
|
||||||
|
? "🛸 Corrélation ÉLEVÉE : suspicion de fascination pour le Double-Double."
|
||||||
|
: hungerRate >= 30
|
||||||
|
? "🛰️ Corrélation MODÉRÉE : possible coïncidence spatio-burger."
|
||||||
|
: "🔬 Corrélation FAIBLE : les aliens préfèrent peut-être les tacos.";
|
||||||
|
|
||||||
|
display(html`
|
||||||
|
<div
|
||||||
|
class="card"
|
||||||
|
style="background:#111726;color:#d8e1ff;border:1px solid #1d2940;"
|
||||||
|
>
|
||||||
|
<h3 style="margin-top:0;">Conclusion du laboratoire absurde</h3>
|
||||||
|
<p>
|
||||||
|
<strong>${hungerRate.toFixed(1)}%</strong> des apparitions OVNI en
|
||||||
|
Californie se produisent à moins de
|
||||||
|
<strong>${hungerRadiusKm} km</strong> d’un In-N-Out.
|
||||||
|
</p>
|
||||||
|
<p style="margin-bottom:0;">${conclusion}</p>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
```
|
||||||
7
observablehq.config.js
Normal file
7
observablehq.config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export default {
|
||||||
|
title: "Observable Workspace",
|
||||||
|
root: "docs",
|
||||||
|
pager: false,
|
||||||
|
toc: true,
|
||||||
|
theme: "dashboard",
|
||||||
|
};
|
||||||
3838
package-lock.json
generated
Normal file
3838
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
package.json
Normal file
21
package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "observable",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Observable Framework workspace",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "observable preview",
|
||||||
|
"build": "observable build"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"observable",
|
||||||
|
"framework"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@observablehq/framework": "^1.13.4",
|
||||||
|
"d3-dsv": "^3.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user