215 lines
5.8 KiB
Markdown
215 lines
5.8 KiB
Markdown
---
|
|
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>
|
|
`);
|
|
```
|