Skip to main content
Showcases
Muze Showcase · 2026-09-05

Quake Pulse

Six decades of large earthquakes, seen through their depth, magnitude, and location.

Loading viz…

What you’re looking at

Each dot is an earthquake of magnitude 6 or greater in the USGS catalogue, from 1965 to the September 2026 snapshot. Size and colour show magnitude; overlapping dots also add brightness. Depth uses a square-root scale so the crowded shallow zone has room to breathe.

Hover or tap a dot for its date, depth, and location. Play reveals the catalogue over ten seconds: new quakes ripple on the globe, which visits four notable events: the largest quake in alternating decades, starting with the 1960s. Each name, year, and magnitude stay on the globe until the next stop. The four plot labels mark those same events outside replay. On a phone, the plot labels are hidden and the globe sits below it. Resizing stops replay and redraws the chart, keeping the selected event and its globe label.

The two bright bands at 10 km and 33 km reflect catalogue default depths, not layers of the Earth. About 80% of the earthquakes in this snapshot are shallower than 70 km.

Data comes from the USGS FDSN event service, downloaded on 5 September 2026. This is a fixed snapshot, not a live earthquake feed. USGS can revise historical events.

How it was built

Muze supplies the SVG scatter, axes, scales, hover dispatch, and tooltip. This example adds the Canvas 2D globe, HTML ring and annotations, custom hit testing, glow filter, and replay. Muze’s chart canvas is not the globe’s HTML <canvas> element.

1. Read the Answer. Studio provides viz and getDataFromSearchQuery(), as shown in the Studio Quick Start. The FIELD block maps your Answer column names; getField(...).data() reads each column once. The adapter checks the required fields and values, creates earthquake records, and derives size, then DataModel.loadDataSync loads them with the schema. Change input names only in FIELD; the later schema and encodings use the example’s internal names.

2. Bind fields to marks. In the point layer, .columns(["time"]) sets x and .rows(["depth"]) sets y. .detail(["id"]) identifies each event, and autoGroupBy.disabled prevents automatic aggregation. .color("mag") and .size("size") encode strength. Time is a temporal dimension with UTC year ticks. Its field-level axis settings use tickInterval for five-year steps on desktop and ten-year steps on phones, and nice: false keeps the domain from rounding outwards. The depth scale uses a square root. Squaring magnitude above 5.9 gives the largest quakes more room.

3. Connect hover to the globe. The custom side effect’s formalName() returns globe-focus, the same name enabled in interaction.highlight.sideEffects. It is registered through ActionModel. Its apply method resolves the selected row to a quake by ID, then updates the globe, ring, and rail. The tooltip separately reads its selection through formatter({ dataModel }) and returns HTML with Operators.html.

4. Position the custom overlays. After animationEnd, the example caches Muze’s point coordinates once and uses them to position the HTML ring and labels. It overrides the layer’s hit test so the largest circle under the pointer wins. Depth rules and names are also HTML, positioned with Muze’s y scale. These are showcase-specific additions, not requirements for a basic scatter plot.

The CSS curtain reveals an already-rendered chart; replay never regenerates its SVG marks. The first and last quake’s rendered positions map pixels to replay times: in the pinned Muze version, temporal axes and marks have slightly different padding. Bloom applies once to the point layer, with screen blending on the marks. The globe batches its dots into five paths, and the count updates once per data-year rather than every frame. The globe drawing details live in createGlobe() below the Muze setup. Desktop and phone use the same code. A resize observer rebuilds the chart and its cached positions; one disposer owns the current render.

Take it with you

Load the CSV into a Worksheet and keep one row per event, including unique text IDs and text places in the Answer. Paste these complete artifacts into Muze Studio. Set the FIELD names at the top of the JavaScript to match your Answer columns. This example expects M6+ events since 1965, numeric UTC milliseconds, latitude/longitude in degrees, and depth from −700 to 0 kilometres. Empty or malformed Answers report an error; events sharing one timestamp remain viewable with replay disabled.

JavaScript

Answer data bindings, Muze scatter and hover, canvas globe, annotations, and replay.

Preview
const { muze, getDataFromSearchQuery } = viz;
const data = getDataFromSearchQuery();

// M6+ earthquakes since 1965, from a snapshot of the USGS event service.
// Muze draws the hero chart (time × depth, colour/size = magnitude) and owns the
// interactions (hover, tooltip). The globe, annotations, replay and rail are glue.
const FIELD = {
  id: "id",
  time: "time",
  lat: "lat",
  lon: "lon",
  depth: "depth",
  mag: "mag",
  place: "place",
};
const MAG_DOMAIN = [6, 9.2];

function readQuakes(data) {
  const columns = Object.fromEntries(
    Object.entries(FIELD).map(([key, field]) => {
      let values;
      try {
        values = data.getField(field)?.data();
      } catch (_) {}
      if (!values)
        throw new Error(`Quake Pulse requires the Answer column "${field}".`);
      return [key, values];
    }),
  );
  if (!columns.id.length)
    throw new Error("Quake Pulse needs at least one earthquake.");
  if (
    Object.values(columns).some((values) => values.length !== columns.id.length)
  )
    throw new Error(
      "Quake Pulse Answer columns must have the same number of rows.",
    );
  const ids = new Set();
  return columns.id
    .map((id, i) => {
      const fail = (field) => {
        throw new Error(
          `Quake Pulse row ${i + 1}: invalid ${FIELD[field]}. Use unique IDs, text places, UTC milliseconds, negative depth in km, and M6+ magnitudes.`,
        );
      };
      if (typeof id !== "string" || !id.trim() || ids.has(id)) fail("id");
      ids.add(id);
      if (typeof columns.place[i] !== "string" || !columns.place[i].trim())
        fail("place");
      const number = (key) => {
        const value = columns[key][i];
        if (
          (typeof value !== "number" && typeof value !== "string") ||
          String(value).trim() === "" ||
          !Number.isFinite(Number(value))
        )
          fail(key);
        return Number(value);
      };
      const q = {
        id,
        time: number("time"),
        lat: number("lat"),
        lon: number("lon"),
        depth: number("depth"),
        mag: number("mag"),
        place: columns.place[i],
      };
      if (
        q.time < Date.UTC(1965, 0, 1) ||
        !Number.isFinite(new Date(q.time).getTime())
      )
        fail("time");
      if (Math.abs(q.lat) > 90) fail("lat");
      if (Math.abs(q.lon) > 180) fail("lon");
      if (q.depth > 0 || q.depth < -700) fail("depth");
      if (q.mag < 6) fail("mag");
      q.size = (q.mag - 5.9) ** 2;
      return q;
    })
    .sort((a, b) => a.time - b.time);
}

function buildViz(muze, data, mountId, options = {}) {
  if (options.signal?.aborted) return null;
  const quakes = readQuakes(data);
  const mount = document.getElementById(mountId);
  let current;
  let timer;
  let disposed = false;
  let plot;
  let width, height;
  const observer = new ResizeObserver(() => {
    if (!plot.isConnected) {
      dispose();
      return;
    }
    clearTimeout(timer);
    if (!plot.clientWidth || !plot.clientHeight) {
      width = height = 0; // Revealing at the same size still needs fresh geometry.
      return;
    }
    if (plot.clientWidth === width && plot.clientHeight === height) return;
    timer = setTimeout(render, 150);
  });
  function render() {
    if (disposed || !mount.isConnected || (plot && !plot.isConnected)) {
      dispose();
      return;
    }
    if (
      plot &&
      (!plot.clientWidth ||
        !plot.clientHeight ||
        (plot.clientWidth === width && plot.clientHeight === height))
    )
      return;
    const selection = current?.selection();
    observer.disconnect();
    current?.dispose();
    current = renderViz(muze, quakes, mountId, dispose, selection);
    plot = mount.querySelector("#quake-plot");
    width = plot.clientWidth;
    height = plot.clientHeight;
    observer.observe(plot);
  }
  function dispose() {
    if (disposed) return;
    disposed = true;
    clearTimeout(timer);
    observer.disconnect();
    current?.dispose();
  }
  render();
  return { dispose };
}

function renderViz(muze, quakes, mountId, onDetached, selection) {
  const mount = document.getElementById(mountId);
  const $ = (id) => mount.querySelector(`#${id}`);
  mount.innerHTML = `
  <div id="app">
    <header>
      <h2>Every earthquake of magnitude 6 or greater, 1965–2026</h2>
      <p id="lede"></p>
    </header>
    <section id="quake-plot" aria-label="Earthquakes by time and depth"></section>
    <footer><p id="note">Bright bands at 10 km and 33 km are catalogue default depths, not geology.
      <span id="source"></span></p></footer>
    <aside>
      <div class="controls"><button id="play" type="button" disabled>Play</button><span class="speed">10-second replay</span></div>
      <div class="globe-wrap">
        <canvas id="globe" width="240" height="240" aria-label="Globe showing earthquake locations"></canvas>
        <p id="globe-label" aria-live="polite"></p>
      </div>
      <p class="count"><b id="count">–</b><span id="since">earthquakes since 1965</span></p>
      <ul id="facts"></ul>
    </aside>
    <div id="overlay"><div id="curtain"></div><span id="ring"></span></div>
  </div>
  <svg width="0" height="0" aria-hidden="true" style="position:absolute">
    <filter id="${mountId}-bloom" x="-5%" y="-5%" width="110%" height="110%" color-interpolation-filters="sRGB">
      <feGaussianBlur in="SourceGraphic" stdDeviation="6" result="wide" />
      <feComponentTransfer in="wide" result="wide"><feFuncA type="linear" slope="1.4" /></feComponentTransfer>
      <feGaussianBlur in="SourceGraphic" stdDeviation="1.2" result="tight" />
      <feMerge><feMergeNode in="wide" /><feMergeNode in="tight" /><feMergeNode in="SourceGraphic" /></feMerge>
    </filter>
  </svg>`;
  mount.style.setProperty("--quake-bloom", `url(#${mountId}-bloom)`);
  const appRoot = $("app");
  let disposed = false;
  let selectedQuake = null;
  let globeRaf = 0;
  let replayRaf = 0;

  const byId = new Map(quakes.map((q) => [q.id, q]));
  const tMax = quakes.at(-1).time;
  const largest = quakes.reduce((a, b) => (b.mag > a.mag ? b : a));
  const deepest = quakes.reduce((a, b) => (b.depth < a.depth ? b : a));
  // Keep alternating decade leaders so each replay stop has more time on the globe.
  const byDecade = new Map();
  for (const q of quakes) {
    const decade = Math.floor(new Date(q.time).getUTCFullYear() / 10);
    if (q.mag > (byDecade.get(decade)?.mag ?? 0)) byDecade.set(decade, q);
  }
  const notable = [...byDecade.values()].filter((_, i) => i % 2 === 0);

  const PALETTE = ["#552078", "#b53473", "#e55c59", "#f6b649", "#fff6c8"]; // M6 → M9+
  const YEAR = 365.25 * 86400e3;
  const xDomain = [Date.UTC(1965, 0, 1), tMax + 0.4 * YEAR];

  const fmtDate = (ms) =>
    new Date(ms).toLocaleDateString("en", {
      day: "numeric",
      month: "short",
      year: "numeric",
      timeZone: "UTC",
    });
  const region = (place) => place.split(", ").at(-1) || place;
  // "2011 Great Tohoku Earthquake, Japan" → "Japan"; "1965 Rat Islands (Aleutians) Earthquake" → "Rat Islands"
  const shortName = (place) =>
    region(place)
      .replace(/^\d{4}\s+/, "")
      .replace(/\s*\(.*?\)/g, "")
      .replace(/\s+-\s+.*$/, "") // "Sumatra - Andaman Islands" → "Sumatra"
      .replace(/^Great\s+/i, "")
      .replace(/\s+Earthquake$/i, "")
      .replace(/\s+region$/i, "");
  const annotation = (q) =>
    `${shortName(q.place)} ${new Date(q.time).getUTCFullYear()} · M${q.mag.toFixed(1)}`;
  const globeLabel = $("globe-label");

  // ───────────────────────────── copy + right rail ─────────────────────────────
  $("lede").textContent =
    `${quakes.length.toLocaleString()} events. Size and colour show magnitude; overlap adds brightness. ` +
    `Depth uses a square-root scale. Hover or tap a quake, or press Play to visit notable events.`;
  $("source").textContent =
    "Source: USGS FDSN event service, snapshot 5 September 2026.";
  $("count").textContent = quakes.length.toLocaleString();

  const fact = (color, htmlText) => {
    const li = document.createElement("li");
    li.style.setProperty("--c", color);
    li.innerHTML = htmlText;
    $("facts").append(li);
    return li;
  };
  const selectedEl = fact("#ffe187", "Hover or tap a quake to select it");
  fact(
    "#fff6c8",
    `Largest <b>M${largest.mag.toFixed(1)}</b> near ${escapeHtml(shortName(largest.place))}, ${fmtDate(largest.time)}`,
  );
  fact(
    "#7a8ce0",
    `Deepest <b>${Math.round(-deepest.depth)} km</b> near ${escapeHtml(shortName(deepest.place))}, ${fmtDate(deepest.time)}`,
  );
  fact(
    "#9a5bd6",
    `About <b>${Math.round((100 * quakes.filter((q) => q.depth > -70).length) / quakes.length)}%</b> are shallower than 70 km`,
  );

  const selectQuake = (q) => {
    selectedQuake = q;
    const ns = q.lat < 0 ? "S" : "N";
    const ew = q.lon < 0 ? "W" : "E";
    selectedEl.innerHTML =
      `Selected <b>M${q.mag.toFixed(1)}</b> near ${escapeHtml(shortName(q.place))}` +
      `<small>${fmtDate(q.time)}, ${Math.round(-q.depth)} km deep, ` +
      `${Math.abs(q.lat).toFixed(1)}°${ns} ${Math.abs(q.lon).toFixed(1)}°${ew}</small>`;
  };

  const RIPPLE = 1.5 * YEAR; // a ripple lasts 1.5 years of replay time (¼ s on the clock)
  const globe = createGlobe();
  if (selection?.quake) {
    selectQuake(selection.quake);
    globe.focus(selection.quake);
    globeLabel.textContent = selection.label;
  }

  // ───────────────────────────── muze ─────────────────────────────
  const { DataModel, ActionModel, Operators } = muze;
  const { GenericSideEffect } = muze.SideEffects.standards;
  const schema = [
    { name: "id", type: "dimension" },
    { name: "place", type: "dimension" },
    { name: "time", type: "dimension", subtype: "temporal" },
    { name: "depth", type: "measure" },
    { name: "mag", type: "measure" },
    { name: "size", type: "measure" },
    { name: "lon", type: "measure" },
    { name: "lat", type: "measure" },
  ];

  // The one quake in a hover/tooltip model (a one-row selection, so getData() is cheap here).
  const quakeOf = (model) => {
    if (!model) return null; // pointer over nothing
    const { data, schema: s } = model.getData();
    return byId.get(data[0]?.[s.findIndex((f) => f.name === "id")]) ?? null;
  };

  // The chart fills its grid cell: 1000×546 on desktop, the full width on a phone (see style.css).
  const chartEl = $("quake-plot");
  const shrink = Math.min(
    1,
    Math.sqrt((chartEl.clientWidth * chartEl.clientHeight) / (1000 * 546)),
  );

  const model = new DataModel(DataModel.loadDataSync(quakes, schema));
  const canvas = (typeof muze === "function" ? muze() : muze)
    .canvas()
    .data(model)
    .columns(["time"])
    .rows(["depth"])
    .detail(["id"])
    .color("mag")
    .size("size")
    .layers([
      {
        mark: "point",
        // Translucent fills, not group opacity: avoid thousands of compositing layers.
        encoding: { opacity: { value: null }, "fill-opacity": { value: 0.7 } },
        // No stock hover highlight: restyling a point inside the bloom-filtered layer re-rasterizes
        // the whole plot (~100 ms). The globe-focus side effect draws a ring in #overlay instead.
        interaction: {
          highlight: {
            sideEffects: { "plot-highlighter": { enabled: false } },
          },
        },
      },
    ])
    .width(chartEl.clientWidth)
    .height(chartEl.clientHeight)
    .config({
      useUTC: true,
      autoGroupBy: { disabled: true },
      columns: { headers: { show: false } },
      legend: {
        color: { show: false, range: PALETTE, domain: MAG_DOMAIN },
        size: { show: false, range: [1.4 * shrink, 12 * shrink] }, // radius px; the bloom adds the halo
      },
      gridLines: { y: { show: false }, x: { show: false } },
      border: { showValueBorders: { left: false, bottom: false } },
      axes: {
        x: {
          showAxisName: false,
          fields: {
            time: {
              domain: xDomain,
              nice: false,
              tickInterval: {
                step: "year",
                multiplier: chartEl.clientWidth > 900 ? 5 : 10,
              },
            },
          },
          tickFormat: (v) => new Date(v.rawValue ?? v).getUTCFullYear(),
        },
        y: {
          showAxisName: false,
          interpolator: "pow", // sqrt depth: gives the crowded 0–70 km zone half the plot
          exponent: 0.5,
          domain: [-700, 0],
          // no 10 km tick: muze sizes a ticked axis as span/minGap × label height, so a 10 km
          // step would demand a 1000 px tall plot and a scrollbar
          tickValues: [0, -33, -70, -150, -300, -500, -700],
          tickFormat: (v) => {
            const d = Math.abs(v.rawValue ?? v);
            return d === 700 ? "700 km" : `${d}`;
          },
          showInnerTicks: false,
        },
      },
      interaction: {
        highlight: { sideEffects: { "globe-focus": {} } }, // hover
        tooltip: {
          formatter: ({ dataModel }) => {
            const q = quakeOf(dataModel);
            if (!q) return [];
            return Operators.html`<div class="quake-pulse-tip"><b>M ${q.mag.toFixed(1)}</b> · ${Math.round(-q.depth)} km deep<br /><span>${escapeHtml(q.place)}</span><br /><i>${fmtDate(q.time)}</i></div>`;
          },
        },
      },
    })
    .mount(`#${mountId} #quake-plot`);

  const ring = $("ring");
  ActionModel.for(canvas)
    // hover is the only interaction here: unhook click → select and drag → brush
    .dissociateBehaviour(
      ["select", "click"],
      ["select", "longtouch"],
      ["brush", "drag"],
      ["brush", "touchdrag"],
    )
    .registerSideEffects(
      // hover → ring the quake, turn the globe toward it and describe it in the rail
      class extends GenericSideEffect {
        static formalName() {
          return "globe-focus";
        }
        apply(set) {
          const q = quakeOf(set.entrySet.model);
          globe.focus(q);
          ring.style.display = q ? "block" : "none";
          if (!q) return;
          globeLabel.textContent = "";
          selectQuake(q);
          ring.style.left = `${q.x}px`;
          ring.style.top = `${q.y}px`;
          ring.style.width = ring.style.height = `${2 * q.r + 8}px`;
        }
      },
    );
  // Touch: a tap hovers (browsers send mouse events after it), but muze also binds long-press →
  // select and touch-drag → brush, which block page scrolling over the chart. Swallow the touch.
  chartEl.addEventListener("touchstart", (e) => e.stopPropagation(), true);
  // With no real pointer the browser "leaves" the plot right after the tap; keep the tapped quake.
  if (matchMedia("(hover: none)").matches) {
    chartEl.addEventListener("mouseout", (e) => e.stopPropagation(), true);
  }

  // ───────────────────────────── after first render ─────────────────────────────
  canvas.once("animationEnd", () => {
    if (disposed || !appRoot.isConnected) {
      if (!disposed) onDetached();
      return;
    }
    const unit = canvas.composition().visualGroup.matrix().geom().getUnits()[0];
    const layer = unit.layers()[0];
    // rc.77 returns one match per call. Each UID lookup is constant-time;
    // collect the drawn points once without reaching into private layer._points.
    const pts = layer
      .data()
      .getUids()
      .flatMap((uid) =>
        layer.getPointsFromIdentifiers([uid], { getAllAttrs: true }),
      );

    // Cache each quake's plot position and radius for the hover ring.
    for (const p of pts) {
      const q = byId.get(p.data.id);
      q.x = p.update.x;
      q.y = p.update.y;
      q.r = p.size;
    }

    // Muze's hit test picks the nearest centre (Voronoi) and only then checks the pointer is inside
    // that one circle, so a big quake loses to any small neighbour whose centre is closer. Replace it
    // on this layer instance: of every circle under the pointer, take the largest (ties → nearest).
    const slack = layer.config().nearestPointThreshold;
    layer.getNearestPoint = (x, y) => {
      let best = null;
      let bestD = Infinity;
      for (const p of pts) {
        const dx = p.update.x - x;
        const dy = p.update.y - y;
        const d = dx * dx + dy * dy;
        const r = p.size + slack;
        if (d > r * r) continue;
        if (
          !best ||
          p.size > best.size ||
          (p.size === best.size && d < bestD)
        ) {
          best = p;
          bestD = d;
        }
      }
      if (!best) return null;
      return {
        id: layer.getIdentifiersFromData(best.source, best.rowId),
        dimensions: [{ ...best.update, width: best.size, height: best.size }],
        point: best,
        layerId: layer.id(),
      };
    };

    // Pin #overlay onto the plot so the ring and labels can be placed in plot pixels.
    const overlay = $("overlay");
    const plot = chartEl
      .querySelector(".muze-layer-point")
      .ownerSVGElement.getBoundingClientRect();
    const app = overlay.getBoundingClientRect();
    overlay.style.left = `${plot.left - app.left}px`;
    overlay.style.top = `${plot.top - app.top}px`;
    overlay.style.width = `${plot.width}px`;
    overlay.style.height = `${plot.height}px`;
    const label = (cls, text, x, y) => {
      const el = document.createElement("span");
      el.className = cls;
      el.textContent = text;
      el.style.left = `${x}px`;
      el.style.top = `${y}px`;
      overlay.append(el);
    };

    // the notable quakes; every other label sits higher so neighbours don't collide
    notable.forEach((q, i) => {
      const cls =
        (i % 2 ? "note hi" : "note") + (q.x > plot.width - 200 ? " flip" : "");
      label(cls, annotation(q), q.x, q.y);
    });

    // depth zones, placed with muze's own y scale
    const yAxis = canvas.yAxes().flat(2)[0];
    label("rule", "", 0, yAxis.getScaleValue(-70));
    label("rule", "", 0, yAxis.getScaleValue(-300));
    label("zone", "shallow", plot.width - 4, yAxis.getScaleValue(-70) - 9);
    label("zone", "intermediate", plot.width - 4, yAxis.getScaleValue(-70) + 9);
    label("zone", "deep", plot.width - 4, yAxis.getScaleValue(-300) + 9);

    // Replay. The CSS curtain animation is the clock: it slides open across the plot (x is time on a
    // linear scale, so its edge is "now") while the globe and the count catch up to that moment.
    const play = $("play");
    const curtain = $("curtain");
    const count = $("count");
    const since = $("since");
    // Use rendered positions: rc.77's temporal axis and marks have different padding.
    const first = quakes[0];
    const last = quakes.at(-1);
    if (last.time === first.time || last.x === first.x) {
      play.title = "Replay needs earthquakes at different times.";
      return;
    }
    play.disabled = false;
    const msPerPixel = (last.time - first.time) / (last.x - first.x);
    const t0 = first.time - first.x * msPerPixel;
    const t1 = t0 + plot.width * msPerPixel;
    const stop = () => {
      cancelAnimationFrame(replayRaf);
      replayRaf = 0;
      overlay.classList.remove("playing");
      play.textContent = "Play";
      globe.show(quakes.length);
      count.textContent = quakes.length.toLocaleString();
      since.textContent = "earthquakes since 1965";
    };
    const start = () => {
      unit.firebolt().triggerPhysicalAction("hover", { criteria: null }); // un-hover: tooltip, ring, globe
      globeLabel.textContent = "";
      overlay.classList.add("playing");
      play.textContent = "Stop";
      const clock = curtain.getAnimations()[0];
      let shown = 0;
      let year = 0;
      const tick = () => {
        if (!appRoot.isConnected) {
          onDetached();
          return;
        }
        const now = t0 + (clock.currentTime / 10_000) * (t1 - t0);
        while (shown < quakes.length && quakes[shown].time <= now) {
          const q = quakes[shown++];
          if (notable.includes(q)) {
            globe.focus(q); // the globe turns to each notable quake and the rail describes it
            selectQuake(q);
            // Keep the name on the globe until the next stop, including after replay ends.
            globeLabel.textContent = annotation(q);
          }
        }
        globe.show(shown, now); // canvas: cheap every frame
        const displayYear = new Date(Math.min(now, tMax)).getUTCFullYear();
        if (displayYear !== year) {
          // DOM text: any repaint makes Chrome re-layerize the 8.5k blended points (~10 ms), so once a year
          year = displayYear;
          count.textContent = shown.toLocaleString();
          since.textContent = `earthquakes by ${year}`;
        }
        replayRaf = requestAnimationFrame(tick);
      };
      tick();
    };
    play.addEventListener("click", () => (replayRaf ? stop() : start()));
    curtain.addEventListener("animationend", stop);
    curtain.addEventListener("animationcancel", stop);
  });
  function dispose() {
    if (disposed) return;
    disposed = true;
    cancelAnimationFrame(replayRaf);
    globe.dispose();
    canvas.dispose();
    model.dispose();
    if (appRoot.parentNode === mount) mount.replaceChildren();
  }
  return {
    dispose,
    selection: () => ({ quake: selectedQuake, label: globeLabel.textContent }),
  };

  // ───────────────────────────── globe ─────────────────────────────
  // Orthographic canvas globe: every quake as a dot in the chart's colours (they trace the plate
  // boundaries on their own) plus a ring on the hovered one. It eases toward whatever the pointer
  // rests on. The replay shows only the quakes up to "now": each new one ripples out (bigger for
  // bigger magnitudes), the globe turns to each notable quake, and the boundaries draw themselves in.
  function createGlobe() {
    const cv = $("globe");
    const ctx = cv.getContext("2d");
    const S = 240; // CSS px; the bitmap is scaled by devicePixelRatio so it stays crisp on retina
    cv.width = cv.height = S * devicePixelRatio;
    ctx.scale(devicePixelRatio, devicePixelRatio);
    const R = 108;
    const cx = S / 2;
    const cy = S / 2;
    const rad = Math.PI / 180;
    let lon0 = -150;
    let lat0 = 10;
    let view = { lon: lon0, lat: lat0 }; // where the globe eases to
    let target = null; // hovered quake
    let shown = quakes.length; // quakes are in time order; the replay grows this from 0
    let now = Infinity; // the replay clock in data time; Infinity = not replaying, no ripples
    let dirty = true;

    const project = (lon, lat) => {
      const λ = (lon - lon0) * rad;
      const φ = lat * rad;
      const φ0 = lat0 * rad;
      const cosc =
        Math.sin(φ0) * Math.sin(φ) + Math.cos(φ0) * Math.cos(φ) * Math.cos(λ);
      if (cosc < 0) return null; // far side
      return [
        cx + R * Math.cos(φ) * Math.sin(λ),
        cy -
          R *
            (Math.cos(φ0) * Math.sin(φ) -
              Math.sin(φ0) * Math.cos(φ) * Math.cos(λ)),
      ];
    };

    const graticule = [];
    for (let lon = -180; lon < 180; lon += 30)
      graticule.push(Array.from({ length: 61 }, (_, i) => [lon, -90 + i * 3]));
    for (let lat = -60; lat <= 60; lat += 30)
      graticule.push(
        Array.from({ length: 121 }, (_, i) => [-180 + i * 3, lat]),
      );

    const draw = () => {
      if (!appRoot.isConnected) {
        onDetached();
        return;
      }
      const dλ = ((view.lon - lon0 + 540) % 360) - 180;
      const dφ = Math.max(-60, Math.min(60, view.lat)) - lat0;
      if (Math.abs(dλ) > 0.02 || Math.abs(dφ) > 0.02) {
        lon0 += dλ * 0.16;
        lat0 += dφ * 0.16;
        dirty = true;
      }
      globeRaf = requestAnimationFrame(draw);
      if (!dirty) return;
      dirty = false;

      ctx.clearRect(0, 0, S, S);
      const shade = ctx.createRadialGradient(cx, cy, R * 0.2, cx, cy, R);
      shade.addColorStop(0, "#101d42");
      shade.addColorStop(1, "#0a1430");
      ctx.fillStyle = shade;
      ctx.beginPath();
      ctx.arc(cx, cy, R, 0, Math.PI * 2);
      ctx.fill();
      ctx.strokeStyle = "#3b496d";
      ctx.lineWidth = 1.5;
      ctx.stroke();

      ctx.strokeStyle = "rgba(60,80,130,.45)";
      ctx.lineWidth = 0.6;
      ctx.beginPath();
      for (const line of graticule) {
        let pen = false;
        for (const [lon, lat] of line) {
          const p = project(lon, lat);
          if (p) pen ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]);
          pen = !!p;
        }
      }
      ctx.stroke();

      // dots coloured and sized by magnitude like the chart. Thousands of separate fills would cost
      // ~50 ms a frame, so the dots are gathered into one path per palette colour: five fills
      const dots = PALETTE.map(() => new Path2D());
      for (let i = 0; i < shown; i++) {
        const q = quakes[i];
        const p = project(q.lon, q.lat);
        if (!p) continue;
        const r = 0.6 + 0.3 * q.size; // M6 0.6 px … M9 3.5 px
        const path =
          dots[
            Math.min(
              4,
              Math.round(
                ((q.mag - MAG_DOMAIN[0]) / (MAG_DOMAIN[1] - MAG_DOMAIN[0])) * 4,
              ),
            )
          ];
        path.moveTo(p[0] + r, p[1]);
        path.arc(p[0], p[1], r, 0, Math.PI * 2);
      }
      ctx.globalAlpha = 0.85;
      dots.forEach((path, i) => {
        ctx.fillStyle = PALETTE[i];
        ctx.fill(path);
      });

      // the newest quakes ripple: a ring grows and fades as it ages. Its size goes with the square of
      // the magnitude above 6, so an M6 is a 1 px blip, an M7 a 4 px ring, an M9 spreads 28 px
      ctx.strokeStyle = "#f6b649";
      ctx.lineWidth = 1.5;
      for (let i = shown - 1; i >= 0 && now - quakes[i].time < RIPPLE; i--) {
        const q = quakes[i];
        const p = project(q.lon, q.lat);
        if (!p) continue;
        const age = (now - q.time) / RIPPLE; // 0 → 1
        ctx.globalAlpha = 1 - age;
        ctx.beginPath();
        ctx.arc(p[0], p[1], (1 + 3 * (q.mag - 6) ** 2) * age, 0, Math.PI * 2);
        ctx.stroke();
      }
      ctx.globalAlpha = 1;

      const p = target && project(target.lon, target.lat);
      if (p) {
        ctx.fillStyle = ctx.strokeStyle = "#fff6c8";
        ctx.lineWidth = 1.2;
        ctx.beginPath();
        ctx.arc(p[0], p[1], 2.5, 0, Math.PI * 2);
        ctx.fill();
        ctx.beginPath();
        ctx.arc(p[0], p[1], 7, 0, Math.PI * 2);
        ctx.stroke();
      }
    };
    globeRaf = requestAnimationFrame(draw);

    let settle = 0; // only turn the globe once the pointer rests on a quake
    return {
      dispose() {
        cancelAnimationFrame(globeRaf);
        clearTimeout(settle);
      },
      focus(q) {
        target = q;
        dirty = true;
        clearTimeout(settle);
        if (q) settle = setTimeout(() => (view = q), 180);
      },
      show(n, t = Infinity) {
        // replay: the first n quakes have happened as of time t
        shown = n;
        now = t;
        dirty = true;
      },
    };
  }
}

function escapeHtml(value) {
  return value
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
}

buildViz(muze, data, "chart");

CSS

Chart-scoped layout, bloom, depth labels, tooltip, and replay curtain, including the phone layout.

Preview
/* Muze mounts tooltips outside #chart. Only our uniquely named content opts in. */
#chart,
.muze-tooltip-box:has(.quake-pulse-tip) {
  --bg: #081229;
  --ink: #d7def2;
  --text: #8795bb;
  --dim: #8c9abd;
  --gold: #ffe187;
  font-family:
    Inter,
    "Avenir Next",
    -apple-system,
    "Segoe UI",
    Roboto,
    "Helvetica Neue",
    Arial,
    sans-serif;
  font-variant-numeric: tabular-nums;
  color: var(--text);

  * {
    box-sizing: border-box;
  }
  table {
    display: table;
    margin: 0;
    overflow: visible;
  }
  td,
  th {
    padding: 0;
    border: 0;
  }
  tr {
    background: transparent;
    border: 0;
  }

  #app {
    position: relative;
    width: min(1300px, 100%);
    height: 700px;
    margin: 0 auto;
    box-sizing: border-box;
    background: radial-gradient(90% 70% at 40% 10%, #0b1735 0%, var(--bg) 60%);
    border-radius: 10px;
    overflow: hidden;
    display: grid;
    grid-template-columns: minmax(0, 1fr) 300px;
    grid-template-rows: minmax(88px, max-content) 1fr 66px;
    grid-template-areas:
      "head rail"
      "chart rail"
      "foot rail";
  }

  /* ── header ── */
  header {
    grid-area: head;
    padding: 22px 0 6px 60px;
  }
  h2 {
    margin: 0 0 6px;
    padding: 0;
    border: 0;
    font-family: inherit;
    font-size: 19px;
    font-weight: 600;
    letter-spacing: -0.01em;
    color: var(--ink);
    line-height: normal;
  }
  #lede {
    margin: 0;
    font-size: 12px;
    line-height: 1.45;
    max-width: 900px;
    color: var(--text);
  }

  /* ── chart ── */
  #quake-plot {
    grid-area: chart;
    position: relative;
  }

  /* ── footer: footnote ── */
  footer {
    grid-area: foot;
    padding: 12px 0 0 60px;
  }
  #play {
    min-width: 52px;
    padding: 6px 12px;
    border: 0;
    border-radius: 5px;
    background: #283044;
    color: var(--gold);
    font: inherit;
    font-size: 12px;
    font-weight: 600;
    cursor: pointer;
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
  }
  #play:hover {
    background: #313a52;
  }
  .speed {
    font-size: 11.5px;
    font-weight: 600;
    color: var(--text);
  }
  #note {
    margin: 0;
    font-size: 10.5px;
    color: var(--dim);
  }

  /* ── right rail: replay controls, globe, count, facts ── */
  aside {
    grid-area: rail;
    padding: 16px 28px 0 24px;
    display: flex;
    flex-direction: column;
  }
  .controls {
    display: flex;
    align-items: center;
    gap: 14px;
    margin-bottom: 4px;
  }
  .globe-wrap {
    position: relative;
    width: 240px;
    height: 240px;
    align-self: center;
  }
  #globe {
    display: block;
    width: 240px;
    height: 240px;
  }
  #globe-label {
    position: absolute;
    bottom: 8px;
    left: 0;
    width: 100%;
    margin: 0;
    padding: 5px 8px;
    border-radius: 3px;
    background: rgba(8, 18, 41, 0.9);
    color: var(--ink);
    font-size: 11px;
    line-height: 14px;
    text-align: center;
    pointer-events: none;
  }
  #globe-label:empty {
    display: none;
  }
  .count {
    margin: 8px 0 12px;
    display: flex;
    flex-direction: column;
  }
  .count b {
    font-size: 40px;
    font-weight: 300;
    line-height: 1;
    letter-spacing: -0.02em;
    color: var(--ink);
  }
  .count span {
    margin-top: 6px;
    font-size: 11.5px;
    color: #909ec1;
  }
  #facts {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 9px;
    font-size: 11.5px;
    line-height: 1.35;
  }
  #facts li {
    position: relative;
    padding-left: 12px;
  }
  #facts li::before {
    content: "";
    position: absolute;
    left: 0;
    top: 5px;
    width: 5px;
    height: 5px;
    border-radius: 50%;
    background: var(--c, #fff);
  }
  #facts b {
    font-weight: 600;
    color: var(--ink);
  }
  #facts small {
    display: block;
    font-size: 10.5px;
    color: var(--dim);
  }

  /* ── muze restyling (pure CSS, no source changes) ── */
  #quake-plot svg text {
    fill: #8795b0 !important;
    font-family: inherit !important;
    font-size: 10.5px !important;
  }
  #quake-plot .muze-axis path,
  #quake-plot .muze-axis line {
    stroke: rgba(120, 150, 220, 0.22) !important;
  }
  /* bloom on the layer group only. Every point <g> also carries .muze-layer-point, so without the
   reset below each of the 8.5k points gets its own filter and every hover re-rasterizes all of them */
  #quake-plot .muze-layer-point {
    filter: var(--quake-bloom);
  }
  #quake-plot .muze-layer-point g {
    filter: none;
    mix-blend-mode: screen;
  }
  /* muze's brush sets touch-action: none inline on touch devices; let the page scroll over the chart */
  #quake-plot g {
    touch-action: auto !important;
  }

  /* tooltip */
  &.muze-tooltip-box {
    background: rgba(8, 14, 32, 0.94) !important;
    border: 1px solid rgba(140, 160, 220, 0.22) !important;
    border-radius: 6px !important;
    box-shadow: 0 6px 24px rgba(0, 0, 0, 0.6) !important;
    padding: 8px 10px !important;
  }
  .quake-pulse-tip {
    white-space: pre-line;
    font-size: 11px;
    line-height: 1.5;
    color: var(--ink);
  }
  .quake-pulse-tip span {
    color: var(--text);
  }
  .quake-pulse-tip i {
    color: var(--dim);
  }

  /* ── overlay: hover ring, annotations and zone labels (main.js pins it onto the plot) ── */
  #overlay {
    position: absolute;
    inset: 0;
    pointer-events: none;
  }
  /* replay: a curtain over the plot shrinks towards the right edge, uncovering 1965 → today in 10 s.
   It is a separate element animated with transform, so the 8.5k-node chart is never touched */
  #curtain {
    display: none;
    position: absolute;
    inset: 0;
    background: var(--bg);
    transform-origin: right;
  }
  #overlay.playing {
    pointer-events: auto; /* the plot can't be hovered or tapped while the globe replays */
  }
  #overlay.playing #curtain {
    display: block;
    animation: quake-sweep 10s linear forwards;
  }
  #overlay.playing .note {
    display: none; /* The globe names each event when the replay reaches it. */
  }
  #ring {
    display: none;
    position: absolute;
    transform: translate(-50%, -50%);
    border: 2px solid var(--gold);
    border-radius: 50%;
  }
  .note,
  .zone {
    position: absolute;
    font-size: 11px;
    line-height: 14px;
    white-space: nowrap;
    text-shadow:
      0 0 4px #081229,
      0 0 8px #081229;
  }
  /* label sits --lift above its quake, just right of a 1px leader line (::before) down to it */
  .note {
    --lift: 38px;
    z-index: 1;
    color: var(--ink);
    padding: 1px 5px;
    border-radius: 3px;
    background: rgba(8, 18, 41, 0.8);
    transform: translate(6px, calc(-1 * var(--lift)));
  }
  .note.hi {
    --lift: 66px;
    z-index: 0; /* its longer leader passes behind any low label in the way */
  }
  .note.flip {
    transform: translate(calc(-100% - 6px), calc(-1 * var(--lift)));
  }
  .note::before {
    content: "";
    position: absolute;
    top: 100%;
    left: -6px;
    width: 1px;
    height: calc(var(--lift) - 16px);
    background: #6d7a9a;
  }
  .note.flip::before {
    left: auto;
    right: -6px;
  }
  /* depth zones: a faint rule at 70 km and 300 km, named at the plot's right edge */
  .rule {
    position: absolute;
    width: 100%;
    border-top: 1px dashed rgba(215, 222, 242, 0.16);
  }
  .zone {
    color: #97a5c9;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    transform: translate(-100%, -50%);
  }

  /* ── phones: one column, chart ≥60vh tall, rail below (main.js sizes the chart from its cell) ── */
  @media (max-width: 900px) {
    #app {
      height: auto;
      margin: 0;
      border-radius: 0;
      grid-template-columns: minmax(0, 1fr);
      grid-template-rows: auto max(60vh, 380px) auto auto; /* muze needs ~380 px for the depth ticks */
      grid-template-areas:
        "head"
        "chart"
        "foot"
        "rail";
    }
    header,
    footer,
    aside {
      padding: 16px 16px 0;
    }
    footer {
      padding-bottom: 16px;
    }
    .note {
      display: none; /* Keep the phone plot clear; replay labels stay on the globe. */
    }
  }
}

@keyframes quake-sweep {
  to {
    transform: scaleX(0);
  }
}

HTML

The mount element. JavaScript creates the chart, reading guide, source note, and rail.

Preview
<div id="chart"></div>

Dataset (CSV)

USGS event id, UTC time in milliseconds, latitude, longitude, negative depth in kilometres, magnitude, and place. Size is derived in JavaScript.