Files
observable/docs/in-n-out-california.md

5.8 KiB

title, toc
title toc
In-N-Out Seismic Challenge (CA) 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)
const [stores, quakes] = await Promise.all([
  FileAttachment("./data/in-n-out-ca-overpass.json").json(),
  FileAttachment("./data/ca-earthquakes-last365d.json").json(),
]);
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);
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];
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>
`);
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`,
      }),
    ],
  }),
);
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);
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>
`);