chore: initial Observable workspace with technical README
This commit is contained in:
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>
|
||||
`);
|
||||
```
|
||||
Reference in New Issue
Block a user