ggplot-rs 0.13.0

A Rust implementation of ggplot2's Grammar of Graphics
Documentation
// DuckDB-Wasm (spatial) → WKT → ggplot-rs (WASM). Each panel is fail-isolated
// (the scatter needs no DuckDB and runs first), so one failure is reported in
// place rather than blanking the page.
//
// Build:  wasm-pack build --target web --out-dir web/pkg --no-default-features --features wasm,canvas

import * as duckdb from "https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@1.29.0/+esm";
import init, { render_geo, render_bar, render_scatter_xy } from "./pkg/ggplot_rs.js";

const set = (id, msg, busy = false) => {
  const el = document.getElementById(id);
  if (!el) return;
  el.textContent = msg;
  el.classList.toggle("busy", busy);
};

const tip = document.getElementById("tip");
const showTip = (text, x, y) => {
  tip.textContent = text;
  tip.style.left = `${x}px`;
  tip.style.top = `${y}px`;
  tip.classList.add("on");
};
const hideTip = () => tip.classList.remove("on");

const detitle = (el) => {
  el.querySelectorAll("title").forEach((t) => {
    t.parentNode.setAttribute("data-tip", t.textContent);
    t.remove();
  });
  return el;
};
const hoverTips = (el) => {
  el.addEventListener("mousemove", (e) => {
    const m = e.target.closest("[data-tip]");
    if (m) showTip(m.getAttribute("data-tip"), e.clientX, e.clientY);
    else hideTip();
  });
  el.addEventListener("mouseleave", hideTip);
};

async function main() {
  set("status", "initialising ggplot-rs (wasm)…", true);
  set("status2", "initialising…", true);
  await init();

  try {
    scatterDemo();
  } catch (e) {
    console.error("scatter:", e);
    set("status2", "scatter error: " + (e.message || e));
  }

  let duck = null;
  try {
    duck = await setupDuck();
  } catch (e) {
    console.error("duckdb:", e);
    set("status", "DuckDB error: " + (e.message || e));
    set("status3", "DuckDB unavailable");
  }
  if (duck) {
    try { await mapDemo(duck); } catch (e) { console.error("map:", e); set("status", "map error: " + (e.message || e)); }
    try { await quakeDemo(duck); } catch (e) { console.error("quakes:", e); set("status3", "earthquakes error: " + (e.message || e)); }
  }
}

async function setupDuck() {
  set("status", "starting DuckDB-Wasm…", true);
  const bundle = await duckdb.selectBundle(duckdb.getJsDelivrBundles());
  const workerUrl = URL.createObjectURL(
    new Blob([`importScripts("${bundle.mainWorker}");`], { type: "text/javascript" }),
  );
  const worker = new Worker(workerUrl);
  const db = new duckdb.AsyncDuckDB(new duckdb.ConsoleLogger(), worker);
  await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
  URL.revokeObjectURL(workerUrl);
  const conn = await db.connect();
  set("status", "loading the spatial extension…", true);
  await conn.query("INSTALL spatial; LOAD spatial;");
  return { db, conn };
}

const registerUrl = async (db, name, url) => {
  const bytes = new Uint8Array(await (await fetch(url)).arrayBuffer());
  await db.registerFileBuffer(name, bytes);
};

// ── Choropleth with continent drill-down ──────────────────────────────────
let allRows = [];
const nameToContinent = {};

async function mapDemo({ db, conn }) {
  set("status", "downloading Natural Earth countries…", true);
  await registerUrl(db, "countries.geojson",
    "https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_110m_admin_0_countries.geojson");

  set("status", "reading geometry…", true);
  const sql = `
    SELECT ST_AsText(geom) AS geometry, NAME AS name, CONTINENT AS continent,
           ln(POP_EST + 1) AS pop_log
    FROM ST_Read('countries.geojson')
    WHERE NAME <> 'Antarctica'`;
  allRows = (await conn.query(sql)).toArray().map((r) => r.toJSON());
  for (const r of allRows) nameToContinent[r.name] = r.continent;

  renderMap(allRows, "World — hover a country, or click to zoom to its continent");
  set("status", `${allRows.length} countries loaded.`);

  const plot = document.getElementById("plot");
  hoverTips(plot);
  plot.addEventListener("click", (e) => {
    const m = e.target.closest("[data-tip]");
    if (!m) return;
    const name = m.getAttribute("data-tip").replace(/: [^:]*$/, "");
    const cont = nameToContinent[name];
    if (!cont) return;
    renderMap(allRows.filter((r) => r.continent === cont), `${cont} — click ⟳ World to reset`);
    document.getElementById("reset").style.display = "";
  });
  document.getElementById("reset").onclick = () => {
    renderMap(allRows, "World — hover a country, or click to zoom to its continent");
    document.getElementById("reset").style.display = "none";
  };
}

function renderMap(rows, title) {
  const plot = document.getElementById("plot");
  plot.innerHTML = render_geo(JSON.stringify({
    geometry: rows.map((r) => r.geometry),
    fill: rows.map((r) => Number(r.pop_log)),
    label: rows.map((r) => r.name),
    width: 960, height: 520, title,
  }));
  detitle(plot);
}

// ── Live USGS earthquakes, coloured by magnitude ──────────────────────────
async function quakeDemo({ db, conn }) {
  set("status3", "downloading USGS earthquakes…", true);
  await registerUrl(db, "quakes.geojson",
    "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/2.5_month.geojson");

  set("status3", "reading geometry…", true);
  const rows = (await conn.query(
    `SELECT ST_AsText(geom) AS geometry, mag, place
     FROM ST_Read('quakes.geojson') WHERE mag IS NOT NULL`,
  )).toArray().map((r) => r.toJSON());

  const eq = document.getElementById("eqplot");
  eq.innerHTML = render_geo(JSON.stringify({
    geometry: rows.map((r) => r.geometry),
    fill: rows.map((r) => Number(r.mag)),
    label: rows.map((r) => r.place),
    width: 960, height: 480,
    title: `${rows.length} earthquakes (M≥2.5), past 30 days — colour = magnitude`,
  }));
  detitle(eq);
  hoverTips(eq);
  set("status3", `${rows.length} earthquakes — hover for the location + magnitude.`);
}

// ── Linked views: raster scatter (brush highlights) → ggplot-rs bar ───────
function scatterDemo() {
  const n = 100_000;
  const x = new Float64Array(n), y = new Float64Array(n), gidx = new Uint32Array(n);
  const cx = [-2, 0, 2.5], cy = [0, 2, -1], names = ["a", "b", "c"];
  set("status2", `generating ${n.toLocaleString()} points…`, true);
  for (let i = 0; i < n; i++) {
    const k = i % 3;
    const r = Math.sqrt(-2 * Math.log(Math.random() + 1e-9));
    const t = 2 * Math.PI * Math.random();
    x[i] = cx[k] + r * Math.cos(t);
    y[i] = cy[k] + r * Math.sin(t);
    gidx[i] = k;
  }

  const canvas = document.getElementById("scatter");
  const empty = new Uint8Array(0);
  const blit = (r) => canvas.getContext("2d").putImageData(
    new ImageData(new Uint8ClampedArray(r.rgba), canvas.width, canvas.height), 0, 0);
  const draw = (sel) => blit(render_scatter_xy(x, y, gidx, names, sel, canvas.width, canvas.height, `${n} points`));

  const t0 = performance.now();
  const res = render_scatter_xy(x, y, gidx, names, empty, canvas.width, canvas.height, `${n} points`);
  blit(res);
  set("status2", `rendered ${n.toLocaleString()} points in ${Math.round(performance.now() - t0)} ms — hover, or drag to brush.`);

  const [px, py, pw, ph] = res.plot, [xe0, xe1] = res.xdom, [ye0, ye1] = res.ydom;
  const sx = pw / (xe1 - xe0), sy = ph / (ye1 - ye0);

  const countAll = () => { const c = { a: 0, b: 0, c: 0 }; for (let i = 0; i < n; i++) c[names[gidx[i]]]++; return c; };
  const renderBar = (c, title) => {
    const el = document.getElementById("scatterbar");
    el.innerHTML = render_bar(JSON.stringify({ category: names, value: names.map((g) => c[g]), width: 300, height: 300, title }));
    detitle(el);
  };
  renderBar(countAll(), `all ${n.toLocaleString()} points`);
  hoverTips(document.getElementById("scatterbar"));

  const px2 = (e) => {
    const r = canvas.getBoundingClientRect();
    return {
      cx: (e.clientX - r.left) * (canvas.width / r.width),
      cy: (e.clientY - r.top) * (canvas.height / r.height),
      ox: e.clientX - r.left, oy: e.clientY - r.top,
    };
  };

  let brushing = null;
  const brush = document.getElementById("brush");

  canvas.addEventListener("mousemove", (e) => {
    if (brushing) return;
    const p = px2(e);
    if (p.cx < px || p.cx > px + pw || p.cy < py || p.cy > py + ph) return hideTip();
    const dx = xe0 + ((p.cx - px) / pw) * (xe1 - xe0);
    const dy = ye0 + (1 - (p.cy - py) / ph) * (ye1 - ye0);
    let best = -1, bestD = Infinity;
    for (let i = 0; i < n; i++) {
      const ex = (x[i] - dx) * sx, ey = (y[i] - dy) * sy, d = ex * ex + ey * ey;
      if (d < bestD) { bestD = d; best = i; }
    }
    if (best >= 0 && bestD < 18 * 18)
      showTip(`group ${names[gidx[best]]} · (${x[best].toFixed(2)}, ${y[best].toFixed(2)})`, e.clientX, e.clientY);
    else hideTip();
  });
  canvas.addEventListener("mouseleave", () => { if (!brushing) hideTip(); });

  canvas.addEventListener("mousedown", (e) => {
    const p = px2(e);
    brushing = p;
    hideTip();
    Object.assign(brush.style, { display: "block", left: `${p.ox}px`, top: `${p.oy}px`, width: "0px", height: "0px" });
  });
  window.addEventListener("mousemove", (e) => {
    if (!brushing) return;
    const p = px2(e);
    Object.assign(brush.style, {
      left: `${Math.min(brushing.ox, p.ox)}px`, top: `${Math.min(brushing.oy, p.oy)}px`,
      width: `${Math.abs(p.ox - brushing.ox)}px`, height: `${Math.abs(p.oy - brushing.oy)}px`,
    });
  });
  window.addEventListener("mouseup", (e) => {
    if (!brushing) return;
    const p = px2(e), s = brushing;
    brushing = null;
    brush.style.display = "none";
    if (Math.abs(p.cx - s.cx) < 5 || Math.abs(p.cy - s.cy) < 5) {
      draw(empty);
      return renderBar(countAll(), `all ${n.toLocaleString()} points`);
    }
    const toData = (mx, my) => [xe0 + ((mx - px) / pw) * (xe1 - xe0), ye0 + (1 - (my - py) / ph) * (ye1 - ye0)];
    const [ax, ay] = toData(Math.min(s.cx, p.cx), Math.max(s.cy, p.cy));
    const [bx, by] = toData(Math.max(s.cx, p.cx), Math.min(s.cy, p.cy));
    const sel = new Uint8Array(n);
    const c = { a: 0, b: 0, c: 0 };
    let total = 0;
    for (let i = 0; i < n; i++) {
      if (x[i] >= ax && x[i] <= bx && y[i] >= ay && y[i] <= by) { sel[i] = 1; c[names[gidx[i]]]++; total++; }
    }
    draw(sel); // selected stay bright, the rest fade
    renderBar(c, `${total.toLocaleString()} selected`);
  });
}

main().catch((e) => {
  console.error(e);
  set("status", "fatal: " + (e.message || e));
});