npm.io
0.1.0 • Published 3d ago

@deepcivic/svelte-spatial

Licence
MIT
Version
0.1.0
Deps
0
Size
322 kB
Vulns
0
Weekly
0

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.md for 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()). Honors prefers-reduced-motion with 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 tooltip snippet).
  • Keyboard — both views are focusable: arrow keys pan, +/- or up/down zoom, Home resets. An aria-live region 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 where Worker is 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

Keywords