# Svelte Spatial — Architecture & Roadmap A level-of-detail timeline UI kit for Svelte 5 for building interactive, browser-based timeline visualizations that stay responsive at hundreds of thousands of events. ## The core problem Svelte Spatial is not a chart library — it is a **level-of-detail (LOD) timeline engine**. The defining challenge is the smooth transition between three rendering regimes as the user zooms: ``` heatmap cells ⇄ event summaries ⇄ individual events (zoomed out) (zoomed in) ``` …across 100k+ records at 60fps. Everything else (annotations, lens view, entity tracks, severity coloring) is comparatively conventional UI layered on top. Almost all the technical risk lives in two places: the **rendering pipeline** and the **aggregation/LOD data structure** that feeds it. ## Design decisions These differ deliberately from the original brief; rationale included. 1. **Aggregation runs client-side, in a Web Worker — not "the backend must pre-aggregate."** A UI *kit* that only works against a bespoke pre-bucketed backend is far less adoptable. The built-in `InMemoryDataSource` ingests plain normalized JSON and aggregates on the fly, so the kit works out of the box. The `DataSource` interface is async, so a backend that already serves pre-aggregated tiers can implement the same interface as a fast path for the 100k+ tier — without the UI knowing the difference. 2. **Runes stay out of the hot path.** Severity binning and aggregation over six-figure arrays happen in plain JS / typed arrays, never in `$derived`. Runes own only UI-scale state: viewport (pan/zoom), selection, config, theme. This is the single most common way these projects end up janky. 3. **Canvas2D first, WebGL as an upgrade path.** With LOD aggregation upstream, the renderer rarely draws more than a few thousand primitives per frame regardless of dataset size — well within Canvas2D's comfort zone, at a fraction of the maintenance cost of a shader pipeline. The `Renderer` interface keeps this swappable, and a `WebGLRenderer` now ships alongside the default `CanvasRenderer`: it batches every bucket/point into one `TRIANGLES` draw call with per-vertex colors (the geometry builder, `buildSliceVertices`, is pure and unit-tested without a GL context). Consumers feature-gate via `WebGLRenderer.isSupported()` and pass `renderer={…}` to ``; if `attach` still throws, `Timeline` falls back to `CanvasRenderer` rather than erroring. Both backends share the severity ramp (`severityRGB`), focus dimming, and crosshair; the animated Ping pulse stays Canvas2D-only by design, advertised via the `Renderer.animatesPing` flag so the rAF loop only forces per-frame redraws for a ping on a backend that actually animates it. 4. **Single rAF render loop, not per-marker reactivity.** Interactions set a `needsDraw` flag; one `requestAnimationFrame` loop paints the latest slice. Data queries are debounced off viewport changes so fast panning doesn't fire a query every frame. ## Layers ``` ┌──────────────────────────────────────────────────────────────┐ │ Components (Svelte 5) Timeline.svelte, lens, annotations │ │ – pan/zoom interactions, rAF loop, debounced queries │ ├──────────────────────────────────────────────────────────────┤ │ State (runes) Viewport (pan/zoom/extent) │ │ – $state for viewport/selection/config; Context for lens sync│ ├──────────────────────────────────────────────────────────────┤ │ Render Renderer interface → CanvasRenderer │ │ – severity→color, heatmap cells, event points, entity lanes │ ├──────────────────────────────────────────────────────────────┤ │ Data DataSource interface │ │ – InMemoryDataSource (sort + binary-search + aggregate) │ │ – aggregate.ts: LOD tiers, selectTier, sliceEvents │ │ – generateSyntheticData: 100k+ test data, day one │ └──────────────────────────────────────────────────────────────┘ ``` ## Data model Normalized input `{ entities, events, relationships }` (see `src/lib/types.ts`). Each event carries a `severity` in `[0, 1]` driving heatmap color and anomaly prioritization. Aggregation prioritizes severity by **max** (not mean), so a single critical event keeps its bucket "hot". ## Roadmap Each step is independently demoable, to de-risk early. - [x] **1. Skeleton** — SvelteKit library + Vitest + demo route + synthetic 100k-event generator. - [x] **2. Static canvas timeline** — events/buckets on a time axis. - [x] **3. Pan/zoom** — rAF-driven viewport in runes, focal-point zoom. - [x] **4a. Client-side aggregation + LOD** — heatmap ⇄ summary ⇄ event transitions (synchronous; see step 4b). - [x] **4b. Move aggregation into a Web Worker** — `WorkerDataSource` proxies the `DataSource` interface to an off-thread `InMemoryDataSource`; falls back to in-thread where no `Worker` exists (SSR/tests). - [x] **5. Entity tracks** — labeled parallel lanes + a time-axis ruler. - [x] **6. Lens / minimap** — full-context overview with a draggable viewport window, synced via the Context API. - [x] **7. Annotation overlay** — click an event to select it; `AnnotationLayer` (DOM/SVG over the canvas) adds notes + tags via a popover, renders pins, and `downloadAnnotations` exports the case as JSON. - [x] **8. Novel interactions** — **Ping** (animated pulse flashing high-severity items, driven by the rAF loop) and **Reveal** (click an entity to focus its track and dim the rest). Both flow through the `RenderContext`, so a WebGL backend could honor them unchanged. *A `WebGLRenderer` now ships* (one batched draw call); Canvas2D remains the default since LOD keeps per-frame primitives in the low thousands. - [x] **9. Precomputed multi-resolution index** — per-tier summary "mipmap" built once; queries are O(visible buckets). See "Scaling ceiling — resolved" below. ## Ecosystem notes Nothing in the Svelte ecosystem does timeline LOD at this scale — the closest prior art is closed, commercial software with no OSS equivalent, so there is no base to fork. We build on: - **[LayerCake](https://layercake.graphics)** — candidate for scale/coordinate plumbing only (Svelte 5 compatible as of 2026; internals still use stores, so treat it as a layout substrate, not a reactivity model). Not yet a dependency. - **[GSTC](https://www.npmjs.com/package/gantt-schedule-timeline-calendar)** — reference for viewport virtualization at 100k+ (different problem domain). - **regl / PixiJS** — candidate WebGL backends if/when Canvas2D is outgrown. ## QA: benchmarked against OSS references The architecture was checked against the closest open-source projects solving the same sub-problems. Each one validates a choice here. | Reference | What it confirms | | --- | --- | | **[Perfetto](https://perfetto.dev/docs/visualization/large-traces)** (trace viewer) | Quantize/aggregate to the *pixel* resolution before drawing; offload work off the render thread; precompute a multi-resolution summary. We mirror all three: `targetCells ≈ width / cellPx`, aggregation in a worker (step 4b), and the `SummaryIndex` mipmap (step 9). | | **[vis-timeline](https://github.com/visjs/vis-timeline)** | Many DOM items destroy performance; clustering/aggregation is mandatory at scale. Validates canvas + LOD buckets over DOM markers, and the **density-driven** switch to detail (vis drops clustering at low item counts — we now switch on event count in the window, not time span alone). | | **[ECharts](https://echarts.apache.org/)** large/sampling mode | Sample/aggregate then render; keep severity prioritized so outliers survive downsampling. Matches our max-severity bucket prioritization. | ### Scaling ceiling — resolved (step 9) `InMemoryDataSource.query` used to re-slice and re-aggregate raw events on every viewport change — O(events-in-window) per query, the real scaling ceiling. It now precomputes a **multi-resolution summary index** (`SummaryIndex`): one sorted bucket array per LOD tier, built once at construction (off the main thread under the worker). The finest tier is computed from events; coarser tiers roll up from it (count sums, severity maxes) when their size is an exact multiple, else rebuild from events. A query is now a binary-searched slice of already-aggregated buckets — O(visible buckets), independent of dataset size: | Dataset | One-time build | Per-query (zoomed-out worst case) | | --- | --- | --- | | Old (re-aggregate) | — | ~127 ms at 200k (a frame-killer) | | 200k events | ~0.8 s | **~0.12 ms** (≈500× faster) | | 500k events | ~2.5 s | **~0.12 ms** (constant in N) | Build uses nested `entity -> (t0 -> bucket)` maps with numeric inner keys to avoid per-event string-key concatenation. Further build speedups (typed-array columns, incremental updates) remain possible but are no longer on the critical path. ### Bugs found and fixed — QA pass 1 (through step 4a) - **Blank-until-interaction race:** the initial data query fired before the canvas had a measured width (`width === 0`), so nothing drew until the first wheel/drag. The first query is now driven by the `ResizeObserver`. - **Stale tier on resize:** container width feeds `targetCells` (and thus the LOD tier), but resizing never re-queried. The observer now re-queries on width change. - **Span-based LOD switch:** the events-vs-buckets decision keyed off visible time span, so sparse data zoomed out drew single-count "buckets" instead of points. It is now density-driven (count of events in the window). ### Bugs found and fixed — QA pass 2 (through step 9) Found by stress-testing dataset swaps in a real browser (Chromium): - **`postMessage` clone failure:** `Viewport.range`/`extent` are Svelte `$state` proxies and aren't structured-cloneable, so every worker `query` threw and the data path silently died. `WorkerDataSource.query` now sends a plain range copy. - **Unhandled rejection on teardown:** `WorkerDataSource.destroy()` rejects in-flight requests, but three `await` sites didn't catch — `Timeline`'s query, `Lens`'s redraw, and `TimelineContext.#load` (the worker builds its index on `init`, ~2.5 s at 500k, before answering `extent`/`trackIds`, so swapping mid-build destroyed the source while `#load` was pending). All three now swallow teardown rejections; `#load` keeps a `#destroyed` flag so genuine load failures still surface. Verified: 0 console errors across repeated swaps. - **Redundant lens redraw:** the lens redraw effect transitively tracked `width`/`extent`, double-firing on resize. Scoped to `ctx.ready` via `untrack`. Verified clean afterward: no worker leak (1 live across 4 swaps), heatmap + lens + 40 tracks all render post-index. ### Bugs found and fixed — QA pass 3 (steps 7–8) - **Popover dismissed by the body's pointer handlers:** the annotation popover and pins live inside the timeline body, so their pointer events bubbled to the body, whose `pointerup` runs hit-testing and cleared the selection *before* the popover button's `onclick` — clicking into the textarea dismissed the popover and "Add" never saved. `AnnotationLayer` now stops propagation for pointer events originating inside it. Verified in Chromium: select → note → add → pin + export, and marker reopen for edit/remove, all work with 0 console errors. ### Svelte 5 surfaces (consumer API) The kit leans on Svelte 5 primitives where they buy real adoptability, not just to use them: - **Bindable state.** `` exposes `bind:range`, `bind:selected`, and `bind:focusedEntity` (`$bindable`). Each is mirrored to the shared `TimelineContext` with a pair of guarded `$effect`s — one observes (context → prop), one controls (prop → context) with the context read/write `untrack`ed and an equality guard, so internal pan/zoom and external writes don't ping-pong. External range writes go through `Viewport.setRange`, which clamps to the extent and minimum span. The range observer has a one-time **seed** step so a consumer-provided initial range (e.g. a viewport restored from a URL) wins over the freshly-loaded extent — while still reading `vp.range` up front each run so the bound value never goes stale after the first interaction. (Both edge cases are covered by `tests/Timeline.test.ts`.) - **Snippets.** `label` (entity-label content), `loading`, and `empty` snippets let consumers customize rendering without forking components. The `loading` and `empty` overlays sit *over* the stage (the canvas stays mounted so the `ResizeObserver`/rAF wiring is never torn down); `empty` is driven by a UI-scale `hasData` flag set when a query resolves, never per-event. - **Entity labels** travel through an optional `DataSource.trackLabels()` channel (in-memory + worker), surfaced as `TimelineContext.labelFor(id)`. - **Reduced motion.** A reactive `prefersReducedMotion()` singleton (one shared `matchMedia` listener; no Svelte version bump) feeds a `reduced` flag into the `PingFrame`, so the renderer draws a steady highlight ring instead of an animated pulse for users who ask for less motion. ### Discovery & accessibility surfaces (Highcharts-inspired) Checked against Highcharts Stock — the commercial reference for dense time-series — and added the affordances it proves users expect, fitted to the LOD model here: - **Hover crosshair + tooltip.** A pointer-tracked vertical guide (drawn in the render loop via `RenderContext.crosshairX`) plus a DOM tooltip reporting the nearest event or aggregated bucket under the cursor. Hit-testing reuses the drawn slice; the `tooltip` snippet customizes content. - **Range selector.** `` offers quick spans (1H/1D/1W/1M/All) and optional datetime inputs, all driving `Viewport.setRange` through the shared context — the same seam the bindable `range` uses. - **Accessibility (WCAG 2.1 AA).** The canvas is otherwise opaque to assistive tech, so: the timeline body is a focusable `application` widget with keyboard pan/zoom (arrows, `+`/`-`, `PageUp/Down`, `Home`); an `aria-live` region announces the visible slice (debounced ~400ms off `TimelineContext.summary` so continuous pan/zoom doesn't flood screen readers); and `` renders the visible slice (`TimelineContext.slice`, kept as `$state.raw`) as a navigable HTML table. `` skips reading the slice entirely, so a collapsed table doesn't recompute per query. Sonification is the obvious next step but deferred. ### Packaging as an SDK The library is built to ship as a standalone npm package (MIT, same license family as Svelte) even though it currently has one consumer. `npm run package` runs `svelte-package` → `scripts/postpackage.mjs` → `publint`, producing a `dist/` with per-module `.d.ts`, a `svelte`/`types` `exports` map, scoped component CSS (no separate import), and `svelte ^5` as a peer dependency. `prepack` rebuilds on publish so the tarball can't go stale. The one non-obvious step is the **Web Worker default**. Vite's `worker-import-meta-url` plugin requires the `new URL(...)` literal to match the on-disk source extension, so the source must say `aggregator.worker.ts` for the app/dev build — but `svelte-package` emits `aggregator.worker.js` without rewriting that string, which would leave a published consumer resolving a `.ts` that isn't in `dist`. `scripts/postpackage.mjs` rewrites the extension to `.js` in the packaged file (and strips colocated `*.test.*` artifacts), so the default worker path resolves the emitted file for SDK consumers. Bundlers that need a different form can still pass `WorkerDataSource`'s `workerFactory`. ### Theming Every color is a namespaced `--sg-*` CSS custom property with an inline default (`var(--sg-stage, #1e1a17)`), so there's no global stylesheet to import and zero config reproduces the DeepCivic look — yet any token is overridable on an ancestor. Canvas-drawn chrome (axis ruler, crosshair) can't read CSS directly, so `Timeline` resolves `--sg-axis-text`/`--sg-axis-tick`/`--sg-crosshair` via `getComputedStyle` — only on mount/resize and on actual theme changes (a `MutationObserver` on the document root), never per query/frame. The crosshair value is run through `resolveCssColor` (a 1px canvas `getImageData` sample) so a `color-mix()`-based token resolves to plain sRGB `rgba()` the WebGL parser can read. The severity heat ramp stays fixed — it encodes data, not brand. An optional `@deepcivic/svelte-spatial/themes/shadcn.css` maps `--sg-*` onto shadcn's semantic tokens (`var(--primary)`, `var(--card)`, …). Because custom properties resolve lazily, that single `:root` mapping also tracks `.dark` for free — no duplicate dark block. > **Naming note:** the public `--sg-*` theming tokens keep the `sg` prefix even > though the package is now published as `@deepcivic/svelte-spatial`. The token > namespace is part of the consumer-facing contract — renaming it would break > every downstream stylesheet — and it is independent of the package name, so it > was deliberately left unchanged. ### Testing strategy Two layers, both under `vitest`: - **Fast unit tests (node env)** for everything pure or rune-in-a-class: the data layer, aggregation/`SummaryIndex`, `Viewport`, `TimelineContext` (load, `labelFor`, derived `summary`, destroy-mid-load guard), the WebGL geometry builder, and the extracted interaction logic (`hitTest`, `applyViewportKey`). Component logic worth testing is *extracted* into pure modules (`src/lib/interaction/`) precisely so it can be covered without a DOM. - **Component tests (jsdom)** under `tests/`, via `@testing-library/svelte`, opted in per-file with `// @vitest-environment jsdom`. A small `Harness.svelte` installs a prepared context so context-consuming components (`DataTable`, `RangeSelector`, `TimelineToolbar`) are tested without the canvas; `Timeline` itself is mounted with stubbed `ResizeObserver`/`rAF`/canvas to cover label rendering, keyboard-driven viewport changes, and the bindable-range seed. Canvas/WebGL/worker *integration* is verified by a browser smoke test (real Chromium), which is the right altitude for GPU/worker behavior. Test files never ship: `tests/` is outside `src/lib`, and colocated `*.test.*` are stripped from `dist` by `scripts/postpackage.mjs`. ## Spatio-temporal extension The kit has a spatial half — a `` brushed against the timeline — added without breaking the timeline API. It landed additively across the milestones below; this section logs the design decisions as they shipped. ### M1 — geo schema, spatial index, intersection (landed) - **Schema (additive).** `TimelineEvent` gains optional `lon`/`lat` (WGS84); events without coordinates simply don't appear on the map and are unchanged on the timeline. New `GeoBounds`, `GeoCell`, and `GeoSlice` types mirror `TimeRange`/`Bucket`/`ViewportSlice`. - **Project once, index projected coords.** Events are projected to a normalized **Web Mercator** unit square ([0,1]×[0,1], y down) at index-build time (`data/projection.ts`), so every query and (later) every frame works in flat x/y with no trig in the hot path. Web Mercator over equirectangular so consumer-supplied GeoJSON basemaps (M3) line up; latitude is clamped to the standard ±85.05°. - **Static quadtree (`data/geoIndex.ts`).** A dependency-free region quadtree built once alongside `SummaryIndex` (off-thread under the worker). Leaf nodes store event indices **sorted by time**, so a spatio-temporal query is a binary-searched time sub-slice per overlapping leaf rather than a per-event scan; internal nodes carry `count`/`maxSeverity` so a purely spatial count (`countInBounds`) is answered from aggregates without descending to leaves. - **Query strategy.** `queryGeo(bounds, range, cellsX, cellsY)` walks the tree for bbox candidates, time-filters each leaf, and either returns individual `points` (few enough) or bins directly into a `cellsX×cellsY` heat grid — a single O(candidates) pass with typed-array writes, no intermediate candidate array in the common zoomed-out case. The timeline's `query()` gains an optional `bounds` filter (the spatial brush): it collects the bbox∧range candidate set from the quadtree and aggregates just those with `aggregate()` (the `SummaryIndex` fast path is unfiltered-only, which is fine — filtered windows are small). - **Wiring.** `geoExtent`/`queryGeo` are optional `DataSource` methods (sources without them render an "unsupported source" state in M3); the worker protocol carries them plus the optional `query` bounds; `WorkerDataSource` sends plain copies of bounds/range so `postMessage` can clone them. - **Measured.** A *typical* (continent-scale) viewport query over 100k geo events runs in **~0.5 ms**; the zoomed-all-the-way-out worst case (every point in view) is O(points-in-view) at ~10 ms and is the case M2's LRU memoization will cache across a pan. Build is ~130 ms for 100k points, one-time and off-thread. See `geoIndex.test.ts` (benchmark logged, `summaryIndex.test.ts` style). - **Deferred to their milestones (not yet done):** the `` component, `GeoViewport`, and the real-Chromium worker/canvas smoke test (M3), since the geo layer adds no UI surface to drive before then. ### M2 — worker cancellation (epochs) + LRU memoization (landed) The two mechanisms both live in a new **`data/workerCore.ts`**, factored out of `aggregator.worker.ts` so the logic is unit-testable on the main thread (Node has no `Worker`). The `.worker.ts` file is now just postMessage plumbing plus a schedule; `WorkerCore` owns the wrapped `InMemoryDataSource`, the epoch map, and the cache. - **Latest-wins epochs, not `AbortController`.** `AbortSignal` isn't structured-cloneable and a busy worker isn't preemptible, so cancellation is cooperative. `WorkerDataSource` keeps a per-channel counter (`query`, `queryGeo` are separate channels — and a bounds-filtered *brush* `query` rides its own `query:brush` channel, independent of the plain timeline `query`, so the two streams never cancel each other when M4 wires up brushing), stamps each request with `++epoch`, and the worker records the highest epoch it has **observed** per channel. A request whose epoch is below that high-water mark is answered immediately as a `stale` response (new `WorkerResponse` variant) instead of computed; the main thread resolves such a promise with `null`, which callers already discard via their own query-id guard (`Timeline.requestQuery` now also treats `null` as a dropped frame). This layers under the existing main-thread `queryId` guard — the main thread ignores late winners, the worker skips dead losers. - **The deferred drain is what makes skipping real.** Worker compute is synchronous and blocking, so a whole burst of pans piles up unseen in the message queue while one query runs. `onmessage` therefore does only the cheap synchronous part — `core.observe(msg)` to bump the epoch high-water mark — then enqueues and schedules a single `setTimeout(0)` drain. The macrotask hop lets the entire queued burst register its epochs *before* any of them is handled, so when the drain runs every superseded request short-circuits and only the newest computes. Without the hop, dequeuing request N can't see N+1..N+k yet and would wastefully compute each. - **Quantized LRU (`data/lruCache.ts`, 32 entries).** Only the spatio-temporal path has real per-call cost (bbox walk + time filter + aggregate); unfiltered `query()` already hits the ~0.12 ms `SummaryIndex` fast path, so it is **not** cached. `queryGeo` and bounds-filtered `query` are memoized on a key that snaps bounds to the cell grid the query implies and the range to a fixed time granularity (`geoCacheKey`/`queryCacheKey`), so sub-cell pans collide onto one entry instead of missing every frame; a full-cell pan or a zoom changes the grid indices and misses correctly. The `Map`-backed LRU promotes on read and evicts the oldest key on overflow. On a hit the response's declared `bounds`/`range` is re-stamped with the *current* request's values (the cells/events are absolute, so only the metadata could otherwise lag a sub-cell pan). `init` clears both cache and epoch history (a fresh dataset invalidates everything). - **Tests (`workerCore.test.ts`, `lruCache.test.ts`).** Epoch skipping (a burst of 3 computes only the newest, asserted via `WorkerCore.computeCount`), per-channel independence, LRU hit/miss/eviction incl. a 33-viewport overflow, back-and-forth-pan cache hits, and quantized-key stability under a sub-cell pan vs. a divergence across a cell boundary. ### M3 — `` component (landed) The spatial half gets its first UI surface. `` is a deliberate structural mirror of `` — the same rendering discipline, one dimension richer — so the two read as one kit rather than two. - **`GeoViewport` (`state/geoViewport.svelte.ts`).** The spatial sibling of `Viewport`, and the only rune-touched hot-path-adjacent state (a rect of four numbers, never per-event). It works in the **normalized projected space** the quadtree and `GeoCell`s already use ([0,1]×[0,1] Web-Mercator, y down), so the render loop maps flat x/y to pixels with no trig; the WGS84 `bounds` getter unprojects on demand for `queryGeo` and `bind:bounds`. Focal-point `zoom` scales both axes together (aspect-preserving) and clamps the factor so neither span leaves `[MIN_SPAN, 1]`; `panBy*` shift-and-clamp to the world square like `Viewport` does to its extent. `matchAspect(w, h)` expands the shorter axis so projected aspect equals pixel aspect — geography stays undistorted. Only resize and `reset()` can break aspect (pan/zoom preserve it), so the component re-matches after those; everything else is a no-op re-match. - **Rendering (`render/GeoRenderer.ts` + `GeoCanvasRenderer.ts`).** A separate `GeoRenderer` seam (the slice and per-frame context differ from the timeline's) with a Canvas2D default, chosen for the same reason as `CanvasRenderer`: LOD aggregation upstream means a few thousand primitives per frame regardless of dataset size. Heat cells zoomed out ⇄ culled points zoomed in, switched on candidate density by the source (`geoPointThreshold`), not the component. Heat color reuses `severityColor`/`severityRGB`, so map and timeline share one ramp; the ping pulse mirrors the timeline's. The render boundary holds: canvas draws all bulk primitives; DOM is only the tooltip and the selection-ring overlay. - **Basemap (`render/basemap.ts`).** Optional consumer GeoJSON (Feature/FeatureCollection/geometry of lines/polygons) is projected to render-space outline paths **once** when the prop changes — never per frame — and stroked under the data layer via `--sg-basemap`. No fetching, no tiles (the dependency-free, swappable-renderer trade-off from decision 4). - **Interactions, extracted and pure.** `interaction/geoKeys.ts` (arrow-key pan, `+`/`-` zoom, `Home` reset — arrows pan in 2-D here, unlike the 1-D timeline) and `interaction/geoHitTest.ts` (nearest point / containing cell under the cursor) are pure modules over `GeoViewport`, unit-tested without a DOM, exactly like `viewportKeys`/`hitTest`. - **Shared context, minimally.** `` reuses the ancestor `TimelineContext` when present (so `selected`/`ping`/`focusedEntity` already mirror the timeline — clicking a map point selects the event in both views) or stands alone from a `source` prop. It owns its `GeoViewport` locally; folding geo state and bi-directional **time-range** brushing into the context is M4. M3 deliberately queries the **full temporal extent**, so the map is complete on its own but not yet filtered by the timeline. Sources without `queryGeo`/geo data render an "unsupported source" state. - **Tests.** `geoViewport.test.ts` (zoom/pan/clamp/aspect/reset), `geoKeys`, `geoHitTest`, and `basemap` pure-module tests, and `tests/GeoMap.test.ts` (jsdom mount: framing, keyboard-driven `bind:bounds`, unsupported state, renderer-attach fallback, initial-bounds restore, live-region announcement) — mirroring `Timeline.test.ts`. **QA pass (M3).** Verified in real Chromium at 100k geo events: the map sustains **~60fps** (16.7 ms mean frame time, p95 16.8 ms) under scripted simultaneous wheel-zoom + drag-pan, clicking a point selects it cross-view (the DOM selection ring appears and `context.selected` is shared with the timeline), and `Home` resets to a pixel-identical framing — no runtime errors. One bug found and fixed: the aria-live summary effect read the render loop's plain (non-reactive) `slice` and so only re-ran when the `hasData` flag toggled, leaving the announced count stale across pans that kept the map populated. Fixed by mirroring the drawn slice into a reactive `$state.raw` (the same a11y seam `` uses via `context.slice`) and deriving the summary from that; guarded by a mount test that asserts the real resolved count (not the initial empty slice) reaches the live region. ### M4 — shared context, brushing, bindables, virtualized table (landed) The two views stop being independent and become one linked instrument: the map filters the timeline in space, the timeline filters the map in time. - **Brush state on the context.** `TimelineContext` grows three fields — `geoViewport` (the map's `GeoViewport`, published so other surfaces can read it), `geoSlice` (`$state.raw`, the drawn map slice, mirrored for accessible / cross-view use like `slice`), and `boundsFilter: GeoBounds | null` (the spatial brush). The **temporal** brush needs no new field — it is just the timeline's existing `viewport.range`. `DataProvider` (component) and `DataContext` (the `TimelineContext` class) are exported as the forward-looking names; the original `TimelineProvider`/`TimelineContext` names stay exported and are the same code, so nothing consumer-facing breaks (decision 6). - **Orthogonal axes = no feedback loop.** The brushing is bidirectional but provably loop-free because the two directions act on *different axes*. `` publishes `vp.bounds → context.boundsFilter` (equality-guarded, `untrack`ed) and reads `context.viewport.range` as its `queryGeo` range; `` passes `context.boundsFilter` as its `query()` bounds filter and reads `viewport.range` as its own range. A map pan changes only `boundsFilter` (→ timeline re-query); a timeline pan/zoom changes only `range` (→ map re-query). Neither write is an input to the other, so there is no ping-pong — the equality guard on `boundsFilter` additionally means a map query triggered by a *timeline* range change (bounds unchanged) doesn't spuriously re-filter the timeline. Both directions ride the **existing 60 ms query debounce**; no new machinery. The unfiltered `` still queries the full extent, so the overview never loses context while the main views are brushed (decision: "Filter semantics for brushing"). - **Bindables.** `bind:bounds` on `` uses the same seed/observe/control pattern as ``'s `bind:range` (a consumer-provided initial value wins the seed; equality guards + `untrack` stop the observe/control effects from looping). `selected` is one shared field on the context, so clicking a point on the map and clicking an event on the timeline select into the *same* slot — `bind:selected` on either component observes the other's selection for free. - **Virtualized ``.** A brushed, event-mode slice can be thousands of rows, so the table windows them: fixed row height, a computed `[startIndex, endIndex)` window (+overscan) off `scrollTop`, and two spacer ``s padding the un-rendered remainder so the scrollbar spans the true height. Only ~visible rows are ever in the DOM; no dependency. The `active={false}` skip (a collapsed table doesn't subscribe to the slice or recompute per query) is preserved, and the sticky header stays put while scrolling. - **Tests.** Context-level brush-state defaults + axis-orthogonality (`tests/context.test.ts`); a mounted map+timeline harness asserting the brush **query counts** — a map pan re-issues a bounds-filtered timeline query, a timeline zoom re-issues a range-filtered geo query, and counts stop growing once idle (no ping-pong) (`tests/brush.test.ts`); `bind:bounds` control path and `` windowing (only ~visible rows mount, spacer spans the remainder). **QA pass (M4).** Verified in real Chromium at 100k geo events over the `WorkerDataSource` path: panning the map filters the timeline (event count tracks the box; panning off the data drops it to 0), and zooming/panning the timeline filters the map — both directions live, no runtime errors, and the virtualized table mounts ~24 rows for a multi-thousand-row slice (exact 28px rows, window shifts on scroll, scrollbar spans the true height). One real bug found and fixed, latent since M2: the worker's **quantized geo cache key was scale-invariant for a centered zoom**. It snapped `west`/`south` to the cell grid whose step (`cw = span / targetCells`) scales *with* the box, so a zoom about the center left the origin index unchanged (`west / cw` is constant when both scale together) and every zoom level collided onto one entry — the map served the first (full-world) aggregation restamped with the new bounds, so zooming never refined the heat grid and the spatial brush never narrowed the count. (It went unseen because M2/M3 tests varied zoom only via `targetCells`, never the span at fixed `targetCells`, and a centered heatmap looks plausibly similar.) Fixed by adding a **separate log-bucketed scale index** (`scaleIndex`, base 1.05 — below the smallest interactive zoom step) to both the geo and bounds-filtered-query keys, so distinct zoom levels get distinct keys while sub-cell *pans* (which don't change the cell size) still collide and hit the cache — preserving M2's pan-hit intent. Guarded by a key-level regression (three centered spans → three keys) and a behavioral one (a centered zoom recomputes and returns strictly fewer events, not a stale cache echo). ### M5 — cross-view interactions (landed) The two views already shared a viewport brush (M4); M5 makes the three *narrative* interactions — Reveal, Ping, Annotate — cross-view too, so a gesture on one canvas reads on both. - **Reveal + Ping were already plumbed** through the geo render context in M3/M4: `GeoRenderContext` carries `focusedEntity` and a `ping` frame, and `GeoCanvasRenderer` dims non-matching points (`focusDim`) and draws the same expanding-ring pulse as the timeline (`prefersReducedMotion` → steady ring; the `animatesPing` contract lets a WebGL backend opt out of per-frame redraw). Both flow from the shared `TimelineContext` (`focusedEntity`, `ping`), so clicking a track label or **Ping anomalies** on the toolbar lights up the map and the timeline from one source of truth. Aggregated heat *cells* can't be attributed to a single entity, so Reveal only dims individual points — documented, not a gap. - **Annotate is the new work.** Clicking a map point already selected the event (shared `selected`); M5 adds the pins and the shared editor: - **Denormalized pin coordinates.** `Annotation` gains optional `lon`/`lat`, copied from the event at creation time. Pin placement needs them because the annotated event may be aggregated into a cell, or filtered out of the map's current bounds ∧ range slice, yet its pin must still show — so the coordinate can't be looked up from the live slice. Non-geo notes simply omit them. - **One editor, shared target.** The authoring popover stays the timeline's `` (per the plan). Its edit target was local state; M5 lifts it to `TimelineContext.editingAnnotationId` (+ `editAnnotation(id)`), so a pin click on *either* view opens the same single editor — no duplicate popover. A fresh event selection supersedes an open edit (new note, not edit); the draft note/tags stay local to the editor and are re-seeded only when the target actually changes (typing is never clobbered). - **``** is a DOM overlay mirroring the timeline's pin layer: it renders a pin per geo-bearing annotation at its projected pixel position (recomputed on pan/zoom via the reactive `GeoViewport`, culled to the body with a small margin), and a pin click routes through `editAnnotation`. It honors the house DOM boundary — canvas draws the bulk data; only tooltip, selection ring, and annotation pins are DOM. - **Tests.** `editAnnotation` clears the selection and sets the shared target (`tests/context.test.ts`); the store carries `lon`/`lat` through (`annotations.test.ts`); a mounted `` renders a pin only for geo-bearing notes, places it at the projected position, and opens the shared editor on click (`tests/GeoAnnotationLayer.test.ts`). **QA pass (M5).** Verified in real Chromium at 10k geo events over the `WorkerDataSource` path, covering both authoring directions and the editor lifecycle: selecting a point on the map → note → a pin on **both** the map (projected) and the timeline (time/lane); authoring from the **timeline** (click an event → note) also pins the map, confirming the denormalized `lon`/`lat` path; clicking a map pin reopens the *same* editor in **edit mode with the note pre-filled** (both from the map pin and the timeline pin); editing and saving updates the note on both views; removing clears the pin on both; panning the map slides the fixed-geography pin with the basemap; **Reveal** dims non-focused points on both canvases and **Ping anomalies** pulses high-severity items on both. No runtime errors (only the demo's blocked web-font request). No bugs found; the pass hardened the refactor with regression coverage: the shared editor pre-fills its draft on open and again after cancel/re-open — a guard on the effect-based seeding that replaced the old synchronous `openMarker` (`tests/AnnotationLayer.test.ts`) — and the map pin layer reacts to annotation removal and viewport pans (`tests/GeoAnnotationLayer.test.ts`). ### M6 — playhead, `SimulationAdapter`, and agent layers (landed) The kit gains a *time cursor* and, on top of it, optional **moving agents** on the map. The verdict from the plan holds: Svelte Spatial ships a pluggable seam, **not a simulation engine** — pathfinding/routing/behavior stay consumer-side; the SDK contributes time state, the viewport, culling/LOD, and the 60fps loop. - **`Playhead` (`state/playhead.svelte.ts`).** UI-scale runes state — `timeMs`, `playing`, `speed`, `loop`, `extent` — and the *only* place playback time lives. It is created lazily on the shared context via `ensurePlayhead(extent)` and stays `null` until a *consumer* asks for it (a `` or a bound `playhead`), so `` hides its play/pause/speed controls and the timeline draws no cursor unless something drives time. Playback is deliberately trivial: `advance(realDeltaMs)` adds `delta × speed` and wraps/clamps at the extent edge — the heavy lifting (positions at a time) is a *pure function of `timeMs`* on the adapter, so scrubbing backward, jumping, and looping all cost the same as playing forward. - **One clock, one loop.** The playhead is advanced by a *single* rAF tick — the map's when a `` is mounted (it publishes `geoViewport`, so the timeline defers by checking `context.geoViewport`), else the timeline's. No second loop, no `$derived`/`$effect` in the tick. The timeline draws a draggable "now" cursor (a warm vertical line + handle, `--sg-playhead`) on its canvas via `RenderContext.playheadX`; grabbing within 6px of it scrubs (pauses + seeks) instead of panning. `bind:playhead` on ``/`` uses the same seed/observe/control pattern as `bind:range`. - **`SimulationAdapter` (`sim/SimulationAdapter.ts`).** A capability *alongside* `DataSource`, fully optional. `getPositionsAt(timeMs, out?)` returns an `AgentSnapshot` — a packed `Float64Array` of lon/lat pairs (+ optional `ids`, `headings`, `kinds`) — and MUST be pure in time. The SDK calls it **at most once per rAF frame, from the tick, never a rune** (house rule), passing a reused `out` buffer so steady playback allocates nothing. `extent?()` clamps the playhead to the span over which agents exist. - **Two render paths, consumer's choice.** With no `agents` snippet, agents ride the **canvas bulk path**: `GeoCanvasRenderer` culls + projects each agent inline (like event points) and draws an oriented triangle (heading present) or dot, colored per kind — 10k agents stay on the existing rAF pass, allocation-free. Providing an `agents` snippet switches to the **DOM path**: `cullAgents` (`sim/agents.ts`, a pure, unit-tested helper) projects the visible subset — decimated with an even stride past `AGENT_SNIPPET_CAP` (500) for LOD — and each gets the snippet on a `transform`-positioned overlay (documented for ~200–500 visible; beyond that, the canvas path). The visible set is written from the tick (a small `$state` array), so DOM overlays move as the clock advances without breaking the "no heavy work in runes" rule. - **Span-relative default speed (decision).** `Playhead.speed` is honestly sim-ms per real-ms, but a dataset spanning weeks is invisible at literal real-time, so when a `` *creates* the clock it seeds a default speed that traverses the extent in ~20s at 1× (and enables `loop`). The toolbar speed selector then offers *relative* multipliers (0.5×–8×) scaling that captured base, so it stays correct whatever absolute rate the app configured. - **Reference adapter is demo-only.** `src/routes/waypointAdapter.ts` (timed-waypoint linear interpolation between hubs) is example code, **not** exported from `src/lib/index.ts` — the core package ships no engine. Exported are only the seam (`SimulationAdapter`, `AgentSnapshot`), the pure helpers (`cullAgents`, `agentKindColor`, `AgentView`), and `Playhead`. - **Tests.** `Playhead` advance/clamp/loop/scrub/setExtent unit tests; `cullAgents` projection/culling/decimation + adapter-contract **purity** (shuffled `timeMs` → identical snapshots) and `out`-reuse; `ensurePlayhead` idempotence on the context; toolbar controls appear only with a playhead and drive play/speed; a mounted timeline scrub via drag; and a jsdom test that the snippet path renders a node only for *visible* agents and updates the set on a scrub. **QA pass (M6).** Verified in real Chromium over the `WorkerDataSource` path with 2k canvas agents: enabling **Agents** shows the playback controls and the timeline "now" cursor (colored, oriented triangle markers move across the map — confirmed by screenshot, not just pixel deltas); pressing **▶** advances the clock (cursor slides) and the agents move; **⏸** stops them; dragging the timeline playhead scrubs, and because positions are pure in time the map agents track the drag **bidirectionally** with no divergence. Toggling Agents off/on mid-session leaves the map working and re-attaches the layer. No runtime errors (only the demo's blocked web-font request). One real bug found and fixed: the **playhead cursor was invisible under the WebGL ("Boost") renderer**. `RenderContext` carried `playheadX`, and `CanvasRenderer` drew it, but `WebGLRenderer.buildSliceVertices` only knew about the hover crosshair — so a consumer running the timeline on the WebGL backend (reachable in the demo: WebGL + Agents both toggleable) saw no cursor while scrubbing still worked, an invisible-but-live control. Fixed by emitting a cursor bar in the vertex buffer alongside the crosshair (the triangle handle stays Canvas2D-only, a documented backend difference), guarded by a `buildSliceVertices` regression and verified in-browser via an element-screenshot diff during WebGL playback (the cursor is the only moving thing on the timeline canvas, so a change proves it renders). Also hardened: the rAF tick **clamps its frame delta to 100 ms** so a backgrounded tab (rAF paused) doesn't teleport the playhead by seconds on refocus — playback resumes where it left off instead of jumping. ### M8 — categorical region choropleth (`GeoRegionLayer`) (landed) A second, categorical aggregation for the map, additive to the density heat grid. The **decision** on the "implement vs. offload to Folium/GeoParquet" question was **implement in-SDK**: the required layer is *per-tick* — it recolors as the timeline range / playhead advances — which is exactly the dynamic-replay job the SDK already does (projection, the rAF loop, the M6 playhead, brushing) and which static tools structurally cannot. The three "new primitives" landed on existing seams, mostly reuse: - **Point-in-polygon aggregation (vs. point-to-grid).** `data/regions.ts` adds a pure, dependency-free even-odd ray cast (`pointInRegion`, holes handled across a polygon's rings; `MultiPolygon` OR'd) over regions projected **once** into the existing normalized space. `GeoIndex.aggregateRegions` reuses the quadtree's `collect` walk to gather each region's bbox ∧ range candidates (so a small region only pays for events near it), ray-casts to confirm containment, and tallies per category. Aggregation is **time-only** — the region set is fixed and small, so a map *pan* just re-renders while a *range* change re-aggregates (that's the per-tick recolor); this is why `queryRegions` takes no bounds. - **Categorical encoding (vs. scalar severity).** A new, deliberately separate axis: `render/categorical.ts` ships the Okabe–Ito colorblind-safe qualitative palette and a stable first-seen category→color map (`buildCategoryColors`, with consumer overrides). The severity heat ramp is untouched — severity is "how hot", category is "what kind", and the two never share a scale. `TimelineEvent` gains an optional `category`; the choropleth reports each region's full `categoryCounts` and its `dominant` (modal) category, which drives the fill hue. - **Filled polygons (vs. outline strokes).** `GeoCanvasRenderer.#drawRegions` fills each projected ring by its dominant color (alpha log-scaled by count, so a busy region reads stronger but stays translucent) with an even-odd rule for holes, then strokes the outline — drawn above the basemap and under the data/point layer. It rides the existing `GeoRenderContext` as a `regionLayer`, the same layer discipline as `basemap`/`agents`. - **Wiring, additive.** `DataSource.queryRegions?(regions, range)` is a new optional capability (in-memory + worker); the worker gets a `queryRegions` request on its own epoch channel and a generation-keyed LRU entry (region geometry is sent only when the `Region[]` reference changes, so per-tick queries carry just the range). `` projects the set once (like the basemap), runs the choropleth query instead of `queryGeo`, region-hit-tests for the tooltip, and announces a choropleth summary to the live region. No public API broke; `generateSyntheticData({ categories })` and `generateGridRegions()` make the path demoable/testable. - **Tests.** `regions.test.ts` (projection + ray cast incl. holes/multipolygon), `regionAggregate.test.ts` (per-region counts/dominant/severity vs. a brute-force reference), `categorical.test.ts` (palette stability + overrides), `workerCore.test.ts` (queryRegions caching, generation bump, per-channel epoch), and a mounted `tests/GeoMapRegions.test.ts` (the choropleth summary reaches the live region; the unsupported state without `queryRegions`). **QA pass (M8).** Verified in real Chromium at 10k geo events over the `WorkerDataSource` path: toggling **Choropleth** fills the region grid in distinct Okabe–Ito category colors (opacity tracking per-region count), with the live region reporting "9917 events across 25 regions, 5 categories"; the density heat grid is correctly suppressed in choropleth mode while agents still draw over the fills. One real bug found and fixed, the same class as QA pass 2's `postMessage` failure: the demo's `regions` array is a Svelte `$state` proxy (with nested coordinate arrays), so sending it raw to the worker threw `DataCloneError` and the choropleth silently never drew. `WorkerDataSource.queryRegions` now sends a plain deep copy (`plainRegions`, a JSON round-trip — regions are plain-JSON geometry), paid once per distinct region set. Guarded by a mock-worker regression that emulates the structured-clone boundary and fails on a non-cloneable region set without the fix. ### Known smells / follow-ups (not bugs) - `Lens` exposes `role="slider"` with epoch-ms `aria-value*` — weak for screen readers; an `aria-valuetext` with formatted dates would read better. - ``'s `bind:bounds` reports the aspect-matched view, so a programmatic `setBounds` is corrected to the viewport aspect on the next observe tick (a map can't show an off-aspect box undistorted). Expected, not a bug; M4's brushing reuses the same observe/control guard pattern as the timeline's bindables. - The virtualized `` keeps only the windowed rows in the DOM, so a screen reader or find-in-page sees just the mounted slice, not the full row set. The `` summary and the `aria-live` region still convey the totals; a future pass could add `aria-rowcount`/`aria-rowindex` for exact virtual-row semantics. - The shared `Playhead` is created lazily but never torn down, so removing a ``'s `adapter` after playback started leaves the toolbar controls (and the timeline cursor) up with no agents to drive. Harmless — the clock just has no visible consumers — but a future pass could ref-count consumers and clear `context.playhead` when the last one detaches. The demo sidesteps it by leaving the adapter mounted and using ⏸ to stop motion. - The region choropleth is always an aggregated view: it does not dissolve into individual points when zoomed in (unlike the density map's cells⇄points switch), and `Region` fills can't be attributed to one entity so Reveal doesn't dim them. Both are natural follow-ups — a points-at-zoom LOD for `queryRegions` mirroring `queryGeo`, and per-category (not just dominant) fill blending. - Agent hover/selection is not wired yet (the plan lists it as optional): the `AgentSnapshot` carries `ids` for it, but `` hit-tests only events and cells today. A natural follow-up reusing the `geoHitTest` pattern.