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);
};
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);
}
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.`);
}
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); renderBar(c, `${total.toLocaleString()} selected`);
});
}
main().catch((e) => {
console.error(e);
set("status", "fatal: " + (e.message || e));
});