svelte-spatial
Level-of-detail geospatial graphs for Svelte 5 — a linked map + timeline that stays responsive at hundreds of thousands of geo-located events.
Zoom out and events collapse into a spatial density heatmap; zoom in and the cells dissolve into individual points — automatically, based on viewport density. Pan the map and the timeline filters to what's in view; brush the timeline and the map follows. No tiles, no fetching, no backend required.
Status: early foundation. The data → spatial aggregation → render → state spine is in place and runs at 100k geo events across linked map and timeline views. The API is pre-1.0 and may change. See
ARCHITECTURE.mdfor the design rationale and roadmap.
Install
npm install @deepcivic/svelte-spatial
Svelte 5 is a peer dependency.
Quick start
A geospatial density map — spatial aggregation runs off the main thread in a worker, so the map stays at 60fps while it indexes:
<script lang="ts">
import { GeoMap, WorkerDataSource, generateSyntheticData } from '@deepcivic/svelte-spatial';
// Or pass your own normalized { entities, events, relationships } JSON,
// where events carry `lon`/`lat`.
const dataset = generateSyntheticData({ eventCount: 100_000, geo: true });
const source = new WorkerDataSource(dataset); // falls back to in-thread in SSR/tests
</script>
<div style="height: 600px">
<GeoMap {source} />
</div>
<GeoMap> draws an aggregated heat grid when zoomed out and switches to
individual points as you zoom in, choosing per view based on point density —
the spatial analog of the timeline's level-of-detail tiers.
Linked map + timeline
The point of the kit is the spatio-temporal pair: a map and a timeline that
share one selection and brush each other, wrapped in a TimelineProvider so
they read the same context.
<script lang="ts">
import {
TimelineProvider, GeoMap, Timeline, RangeSelector,
DataTable, WorkerDataSource
} from '@deepcivic/svelte-spatial';
const source = new WorkerDataSource(dataset);
</script>
<TimelineProvider {source}>
<RangeSelector /> <!-- 1H / 1D / 1W / 1M / All quick ranges -->
<div style="height: 480px">
<GeoMap /> <!-- pan/zoom the map; the timeline follows -->
</div>
<div style="height: 220px">
<Timeline /> <!-- brush a time range; the map follows -->
</div>
<DataTable /> <!-- accessible table of the visible slice -->
</TimelineProvider>
Cross-view interactions:
- Spatial brushing — pan or zoom the map and the timeline re-queries to the events inside those geographic bounds. Brush a range on the timeline and the map re-aggregates to that window. The two brushes settle without ping-ponging.
- Shared selection — click an event on either surface and it's spotlighted on
both;
<DataTable>lists the same visible slice. - Ping — flash high-severity events with an animated pulse
(
ctx.pingAnomalies()). Honorsprefers-reduced-motionwith a steady highlight ring instead of an animated pulse. - Annotate — zoom in, click an event, attach a note + tags; pins render on
both the map and the timeline. Export via
downloadAnnotations(ctx.annotations). - Hover — a crosshair + tooltip follow the cursor, reporting the nearest event
or aggregated cell (customizable via the
tooltipsnippet). - Keyboard — both views are focusable: arrow keys pan,
+/-or up/down zoom,Homeresets. Anaria-liveregion announces the visible slice, and<DataTable>renders an accessible table fallback of the same data.
TimelineProvider is also exported as DataProvider — the same component under
a name that reflects it now hosts spatial state too.
Choosing a data source
Both examples use WorkerDataSource, the right default at scale:
InMemoryDataSource— aggregates on the main thread. Best for < 10k events or SSR-only use, where there's no worker and the build cost is trivial.WorkerDataSource— builds the spatial + temporal index and answers queries off-thread, so the main thread stays at 60fps. Best above ~10k events. It transparently falls back to in-thread aggregation whereWorkeris unavailable (SSR, some test environments), so the same code path works everywhere.
Both implement the same DataSource interface, so you can swap one for the
other without touching the rest of your UI.
Bring your own data
Events follow a normalized schema. lon/lat (WGS84 degrees) place an event on
the map — events without coordinates simply don't appear on the map and are
unaffected on the timeline. severity (0–1) drives heat color and anomaly
prioritization on both surfaces:
import { InMemoryDataSource, type TimelineDataset } from '@deepcivic/svelte-spatial';
const dataset: TimelineDataset = {
entities: [{ id: 'a', label: 'Station A' }],
events: [
{ id: 'e1', time: Date.now(), entityIds: ['a'], severity: 0.9, lon: -73.98, lat: 40.75 }
]
};
const source = new InMemoryDataSource(dataset);
Basemaps (bring your own outlines)
<GeoMap> draws a consumer-supplied GeoJSON outline layer (country/state
boundaries, coastlines) under the data. There is no tile fetching and no network
— you pass the geometry, it's projected once, and strokes with a flat transform:
<script lang="ts">
import { GeoMap } from '@deepcivic/svelte-spatial';
import world from './countries.geo.json'; // any GeoJSON FeatureCollection
</script>
<GeoMap basemap={world} />
Control & customize (Svelte 5)
Both <GeoMap> and <Timeline> expose bind: props so a parent can drive or
observe state — deep-link the map bounds to the URL, sync two views, or build a
side panel off the selection:
<script lang="ts">
import { GeoMap } from '@deepcivic/svelte-spatial';
let bounds = $state(); // { west, south, east, north } WGS84 — read or write
let selected = $state(null); // the clicked event, shared with <Timeline>
</script>
<GeoMap bind:bounds bind:selected />
Snippets customize rendering (tooltip content, plus loading, empty, and unsupported-source states):
<GeoMap>
{#snippet tooltip(d)}<b>{d.label}</b> — severity {d.severity.toFixed(2)}{/snippet}
{#snippet loading()}Building spatial index…{/snippet}
{#snippet empty()}No events in view.{/snippet}
{#snippet unsupported()}This source has no geo data.{/snippet}
</GeoMap>
Moving agents (optional)
Provide a SimulationAdapter and <GeoMap> creates a shared Playhead and
draws moving agents at the current playback time each frame — on the canvas bulk
path by default, or via an agents snippet for rich per-agent markup at low
counts. Fully optional; a map without one behaves exactly as above.
<GeoMap {adapter} bind:playhead />
Categorical choropleth (regions)
Pass regions — an array of GeoJSON-style polygons — and <GeoMap> switches from
the density heat grid to a categorical choropleth: it aggregates events into
each polygon over the current time range and fills it by the dominant event
category. Because the aggregation is keyed on the timeline range, the fill
recolors as you brush the timeline or play the playhead — a per-tick replay,
not a static snapshot.
<script lang="ts">
import { GeoMap } from '@deepcivic/svelte-spatial';
import regions from './council-wards.geo.json'; // [{ id, label?, geometry }]
</script>
<GeoMap {regions} />
Events carry an optional category (a kind, e.g. "fire" / "medical") —
orthogonal to severity (a magnitude). Categories are colored with a built-in
Okabe–Ito colorblind-safe palette in
first-seen order; pin your own with categoryColors:
<GeoMap {regions} categoryColors={{ fire: '#e4572e', medical: '#2e7be4' }} />
Each Region is { id, label?, geometry }, where geometry is a GeoJSON
Polygon or MultiPolygon (holes supported). The choropleth is aggregated
in the worker (point-in-polygon over the spatial index), so it stays responsive
at 100k+ events. generateGridRegions(bounds, cols, rows) builds a quick grid for
prototyping. This is an aggregated view — it doesn't dissolve into individual
points on zoom (that's the density map's job); use a plain <GeoMap> for that.
Export
Export the currently filtered slice (the events inside the map bounds ∧
timeline range) or your annotations, as CSV or JSON. <TimelineToolbar> exposes
these under its Data ▾ menu; the same functions are callable directly:
import {
downloadCSV, downloadJSON, // the filtered events
downloadAnnotations, // annotations as JSON (existing)
downloadAnnotationsCSV, // annotations as CSV
eventsToCSV, annotationsToCSV // pure serializers (no download)
} from '@deepcivic/svelte-spatial';
downloadCSV(events); // events.csv
downloadJSON(events, 'slice.json');
Event exports cover the individual events resolvable from the current view — when zoomed out to heat cells/buckets there are no raw rows, so the toolbar disables them with a hint to zoom in. (KML is on the roadmap, not shipped.)
Theming
Every color is a namespaced CSS custom property with a built-in default, so the
kit looks right with zero config and re-skins by setting --sg-* on any
ancestor:
.my-map {
--sg-stage: #10141f; /* canvas surface */
--sg-stage-foreground: #e6e9f0;
--sg-primary: #4f7cff; /* toolbar / range buttons */
--sg-grid: #2a3350; /* gridlines, axis ticks */
}
Tokens cover the stage (--sg-stage[-foreground|-muted], --sg-grid), accents
(--sg-primary[-hover|-foreground], --sg-ring, --sg-pin, --sg-danger),
light surfaces (--sg-surface[-foreground], --sg-border,
--sg-muted-foreground), floating panels (--sg-panel[-foreground|-border]),
and the canvas-drawn axis + crosshair (--sg-axis-text, --sg-axis-tick,
--sg-crosshair).
Using shadcn? Import the bridge once and the kit adopts your shadcn tokens and light/dark mode automatically:
import '@deepcivic/svelte-spatial/themes/shadcn.css';
Swap the renderer (WebGL "Boost")
Rendering is pluggable on both surfaces. The default Canvas2D renderers are plenty fast because LOD keeps per-frame primitives low, but a WebGL renderer batches every cell/point into one GPU draw call for the heaviest views — drop it in and fall back where WebGL is unavailable:
<script lang="ts">
import { Timeline, CanvasRenderer, WebGLRenderer } from '@deepcivic/svelte-spatial';
const renderer = WebGLRenderer.isSupported() ? new WebGLRenderer() : new CanvasRenderer();
</script>
<Timeline {renderer} />
Both honor the same Renderer interface, severity ramp, focus dimming, and
crosshair. (The animated Ping pulse is a Canvas2D embellishment; under WebGL,
high-severity items still read hot via the color ramp.)
Bring your own backend
InMemoryDataSource aggregates client-side so the kit works with no backend. If
you already serve pre-aggregated spatial cells or time-bucket tiers, implement
the same DataSource interface — the queryGeo/geoExtent methods answer the
map, query answers the timeline — and the UI is none the wiser:
import type { DataSource } from '@deepcivic/svelte-spatial';
class MyBackendSource implements DataSource {
extent() { /* time range of the dataset */ }
trackIds() { /* entity ids */ }
query(range, targetCells, bounds) { /* buckets/events for the timeline viewport */ }
geoExtent() { /* geographic bounds, or null when no geo data */ }
queryGeo(bounds, range, cellsX, cellsY) { /* heat cells or points for the map */ }
}
Architecture in one breath
Pluggable DataSource (client-side worker aggregator or backend adapter) →
LOD aggregation, temporal (heatmap ⇄ summary ⇄ event) and spatial (density
cells ⇄ points) → pluggable Renderer (Canvas2D now, WebGL-ready) → runes
viewport state driving a single rAF loop, with the map and timeline linked
through one shared context. Runes stay out of the hot path; heavy aggregation
does not run in $derived. Full rationale in ARCHITECTURE.md.
License
MIT