← Back to Notebook
FIELD NOTES

geegobi: I rebuilt the one GGobi feature nobody replaced

A browser-based Grand Tour, geodesic math validated against R's tourr package — now with a WebGL2 renderer that holds 60fps at 50,000 points on real integrated graphics, and picks itself automatically

Before UMAP, before t-SNE, before "embedding visualization" was even a category, there was a piece of 1990s statistical software called GGobi that had something called the Grand Tour. Instead of picking one projection of your high-dimensional data and staring at it, GGobi continuously animated through a smooth, dense sequence of every possible 2D projection. You didn't choose a view — you watched all of them, and your eye caught structure that only exists at certain angles: clusters, holes, outliers that a single static plot would never surface. It was a genuinely good idea. It also got stuck in desktop C/GTK+ software that's barely maintained now, so almost nobody under 40 has ever seen it work.

geegobi is my attempt to bring that idea back, in a browser tab, no install. It's a from-scratch TypeScript implementation of the actual math GGobi used — geodesic interpolation on the Grassmannian manifold, Asimov's 1985 algorithm as refined by Buja et al. in 2005 — walking continuously between random orthonormal projection bases so transitions are smooth instead of jumpy. On top of that: a windowed UI modeled on GGobi's own floating-panel layout.

What it actually does right now

Play/pause/speed on the tour. Manual manipulation — grab a variable and rotate the projection by hand, five different modes (Oblique, Vert, Horiz, Radial, Angular), so it's not just something you watch, it's something you can steer. A parallel-coordinates view linked to the tour by shared selection state — brush a cluster, keep the tour running, and the same points stay highlighted in both views. That linked-brushing trick is, as far as I can tell, GGobi's single most useful feature and the one nobody rebuilt for the web. CSV drag-and-drop, an import wizard (preview, column typing, include/exclude), built-in Iris and Palmer Penguins datasets if you want zero setup, PNG export.

The part I actually trust: a correctness harness that doesn't take the agents' word for it

Most of geegobi's implementation came out of a small fleet of coding agents, not by hand — the same workflow the last two posts on this notebook described. What made that safe enough to trust, instead of just fast, was boring infrastructure: a task-spec template (exact files, an exact validation command, "done" defined as observable behavior, not "tests pass"), git worktrees so parallel agents can't collide on shared files — and, the one I'd point to first, a real correctness harness. I generated 14 ground-truth geodesic test cases from the canonical R package this algorithm comes from (tourr), running R inside a container so nothing in CI ever needs R installed, and wrote tests that require my JS implementation to agree with R's numbers at every step. The first day that harness existed, it caught a real, live bug — a visible "frame snap" at tour transitions that an earlier, eyeballed fix had missed. There's also a property-test layer (orthonormality, continuity, degenerate inputs) and a Playwright suite that actually starts the app, loads data, watches the tour animate, and checks for zero console errors. 76 core tests, all green.

Update, 2026-08-01: the WebGL renderer landed

The section below described a renderer interface and a bench harness with an empty WebGL column — measurement and contract built before the implementation. That implementation now exists. A four-task agent-fleet wave (tasks 07a–07d) shipped a raw WebGL2 @geegobi/renderer-webgl package (zero runtime dependencies beyond @geegobi/core — no Regl, no twgl, no gl-matrix, to keep the bundle small for a future CDN-embed tier), plus automatic backend selection: the demo now probes for WebGL2 at startup and switches renderers at a 10,000-point threshold, falling back to Canvas2D (and telling you which backend is active) if WebGL2 isn't available.

This time the numbers are from real graphics hardware, not just the sandboxed CI runner: on an AMD Radeon 780M integrated GPU, WebGL2 holds 50,000 points at 60fps, vsync-capped, during an animated tour — render() alone measures roughly 808fps uncapped. Canvas2D on that same hardware falls well short of 60fps at 50k, matching the shape (if not the exact numbers) of the software-rasterizer results below. The sandboxed no-GPU environment that produced the original table still shows Canvas2D at 20fps at 50k under SwiftShader — consistent with the fallback path the threshold is designed around.

Update, 2026-09-03: the Canvas-vs-WebGL pixel-parity harness (e2e/renderer-parity.spec.ts) is now fully resolved, not open work — it closed out later the same day it was first measured (2026-08-01). The initial overlap-blending theory was wrong: a controlled single-point probe showed Canvas/WebGL alpha compositing is provably identical, and the real cause was that Chromium/Skia's arc() doesn't place its antialiased edge at the geometric radius (it's off by up to ~0.12px, non-monotonically with radius) — which dominates the diff on small discs where half the pixels are edge pixels. Fixed with a measured skiaEdgeRadius() calibration table in @geegobi/renderer-webgl. All three cases now pass comfortably inside the unchanged 2% bound: 1.23% / 1.43% / 1.73%. Two caveats worth keeping: the calibration is specific to Chromium's rasterizer, and the remaining headroom under 2% is thin because some residual anisotropy in Skia's antialiasing can't be calibrated away.

The original bench harness result (Canvas2D, before WebGL existed)

Late on 2026-07-31 I started this work: getting geegobi past Canvas2D so it can handle real dataset sizes, not just Iris-scale toy data. The approach mirrors the correctness-harness instinct above — build the measurement and the contract before the implementation, not after. Three things landed and merged that night, in order:

  • A pluggable ScatterplotRenderer interface in @geegobi/core — types only, no DOM access, no runtime logic, locked down by a conformance test so a future rename fails loudly at compile time. The existing Canvas2D renderer was refactored to implement it, and the demo now holds its renderer at interface type instead of a concrete class. This is the seam the WebGL renderer above plugs into without touching the tour logic, the demo wiring, or the Canvas implementation.
  • A Playwright-driven FPS bench harness (bench/) that loads synthetic datasets at 10k/50k/100k points and runs a fixed 300-frame tour segment (seeded, so it's reproducible) through whichever renderer is under test, reporting median FPS, p95 frame time, and a project/render time split. It's built as a 3×2 table — three sizes, two renderer backends.
  • Matching e2e coverage: a dev-only synthetic-dataset loader with an on-screen FPS counter, an e2e spec that loads 50k points and asserts the tour actually animates and reports a real FPS number, and the Canvas-vs-WebGL pixel-compare spec discussed above.

At the time this table was written, the WebGL column was empty — every "webgl2" row reported "unavailable" because the package didn't exist yet. Here's the Canvas2D-only result it produced, all from the sandboxed, no-GPU CI runner (bench/results/*.json, two independent runs, consistent between them):

PointsRendererMedian FPSp95 frame time
10,000Canvas 2D~60~17ms
50,000Canvas 2D20~67–83ms
100,000Canvas 2D~9–10~117–233ms

The bench's own GPU field for these runs reports SwiftShader Device (Subzero), a software rasterizer, not real graphics hardware — which is exactly why the real-GPU number above (60fps at 50k on a Radeon 780M) is the one that actually answers the question this table raised.

What's not here yet

No Guided Tour (the mode that optimizes toward "interesting" projections via an index function instead of moving randomly) — still Grand Tour only. The renderer-parity gap described above is closed as of 2026-08-01 (see update above). And no public deployment: right now the only way to run this yourself is git clone + npm install && npm run dev. It's a static Vite app with no server component, so a free static deploy is plausibly a quick add, but that hasn't happened yet either.

If you work with anything past 3 variables — embeddings, clustering output, whatever's sitting in a dataframe with too many columns for a scatterplot — it's worth a look to see why an idea from thirty years ago still holds up. And if you're curious what "engineering discipline before the flashy part" looks like in practice, the bench-harness-before-renderer sequence above is a smaller, more current example of the same instinct that built the correctness harness in the first place.

Worth flagging
  • No public demo deployment exists yet — this post links to the repo, not a hosted app. Running it yourself means cloning and starting the dev server.
  • The WebGL2 renderer now exists and is merged, with automatic backend selection. The 60fps-at-50k number is from real hardware (AMD Radeon 780M iGPU), not the software rasterizer used for the original Canvas2D-only table further up.
  • The Canvas2D numbers in the middle table were measured on a software GPU rasterizer (SwiftShader), not real graphics hardware — kept in the post to show the shape of Canvas2D's degradation, not as a hardware performance guarantee.
  • The Canvas-vs-WebGL pixel-parity check is resolved (as of 2026-08-01) — all three cases now pass at 1.23–1.73% against the unchanged 2% bound, after fixing a Skia antialiasing-radius calibration bug in the renderer. Not silently loosened; the tolerance never changed.
  • Every other specific number in this post (14 fixtures, 76 core tests, 5 manipulation modes) was checked against DEVLOG.md, the agent playbook, and the live repo before publishing.