chore: initial Observable workspace with technical README

This commit is contained in:
Kerboul
2026-03-19 16:28:35 +01:00
commit c390caf2e2
16 changed files with 4980 additions and 0 deletions

6
docs/components/hello.js Normal file
View File

@@ -0,0 +1,6 @@
export function helloCard(text) {
const el = document.createElement("div");
el.className = "card";
el.textContent = text;
return el;
}

View 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));

View 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())));

View 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
View File

@@ -0,0 +1,8 @@
const now = new Date().toISOString();
process.stdout.write(
JSON.stringify({
message: "Sample data loader OK",
generatedAt: now,
}),
);

View 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));

View 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
View 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
View 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 lAnimal Style (OVNI x In-N-Out)](./ufo-animal-style-paradox)
```js
display(`Date: ${new Date().toLocaleString()}`);
```

View File

@@ -0,0 +1,339 @@
---
title: Paradoxe de lAnimal Style
toc: false
---
# 🛸 Le Paradoxe de lAnimal 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> dun In-N-Out.
</p>
<p style="margin-bottom:0;">${conclusion}</p>
</div>
`);
```