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

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