Skip to content

How Graticule Works

Graticule is a fully client-side map linework tool. There is no backend, no server-side rendering, no data upload endpoint. Every file parse, topology operation, and render happens in the browser. This post walks through how that actually works — the data model, the processing pipeline, the worker architecture, and the rendering loop — as you’d explain it to a developer joining the project.


UI COMPONENTS CatalogPanel Browse & search catalog File upload modal MapCanvas HTML Canvas 2D · pathCache builder + paint $effects Pan / zoom / rotate · globe sphere, halo, shadow LayersPanel Layer stack · style editor Processing controls REACTIVE STATE ($state) layers[] Layer objects style · processing · hasTopology projection id · rotate interactionMode canvasStyles background · graticule ocean · halo · shadow history 50-entry snapshots undo / redo stack pathCache Map<id, CachedChunk[]> fetch / upload TOPOLOGY PIPELINE (plain JS Maps, outside $state) rawTopologyData As fetched from CDN or converted from upload Map<layerId, Topology> Stage 1 simplifiedTopologyData Post-Mapshaper pre-Chaikin Map<layerId, Topology> Stage 2 workingTopologyData Post-Chaikin Renderer + exporter read this Map<layerId, Topology> WEB WORKERS simplify.worker.js Pre-built Mapshaper bundle · served as static file Topology-aware simplification (shared borders stay aligned) Douglas-Peucker · Visvalingam · Weighted Visvalingam geo.worker.ts Built by Vite · stores topology per layer (avoids re-transfer) Chaikin smoothing · BFS + Hilbert path chunking Bezier arc computation (screen-space)

The three-panel layout interacts with Svelte 5 $state reactive stores. Topology data lives separately in plain JS Maps that are invisible to Svelte’s reactivity system. Two web workers handle the computationally heavy work off the main thread.


The central type is Layer:

interface Layer {
id: string;
datasetId: string;
name: string;
visible: boolean;
loading: boolean;
error: string | null;
hasTopology: boolean; // reactive signal: workingTopologyData is ready
style: LayerStyle; // fill, stroke, point shape/size
processing: LayerProcessing; // simplification + smoothing + bezier settings
geometryTypes: string[];
bezierCacheKey: number; // bumped on bezier changes to invalidate render cache only
}

Geography data lives in three plain JavaScript Maps kept outside $state:

rawTopologyData ← fetched from CDN or converted from upload
↓ (Mapshaper simplification via simplify.worker.js)
simplifiedTopologyData ← post-simplification, pre-smoothing
↓ (Chaikin smoothing via geo.worker.ts)
workingTopologyData ← what the renderer and exporter read

All three are Map<string, Topology> keyed by layer id. They’re plain JS because Svelte 5’s deep_read() would traverse every coordinate on every reactive update. A world countries dataset at 10m resolution has roughly 500,000 coordinate pairs. Wrapping that in a reactive proxy causes multi-second freezes on any UI interaction.

The reactive signal that tells the renderer “data is ready” is layer.hasTopology: boolean — a small scalar on the Layer object, which is reactive. The renderer reads workingTopologyData.get(layer.id) directly only when hasTopology is true.


The built-in catalog is a static JSON index served alongside the app. On page load, +page.ts fetches the manifest and passes it to initCatalog(). Each dataset entry has a filePath pointing to a TopoJSON file on the CDN.

When a user clicks a dataset, addLayer() pushes a new Layer to layers[] with loading: true, hasTopology: false, fetches the TopoJSON, stores it in rawTopologyData, and runs the processing pipeline. When complete, layer.hasTopology = true signals the canvas to start building paths.

fileUpload.ts supports six formats: GeoJSON, TopoJSON, Shapefile (zip), KML, KMZ, GPX, and CSV. All formats are parsed to a GeoJSON FeatureCollection. TopoJSON is the exception — it passes through directly without a round-trip.

For everything else, the upload modal converts GeoJSON to TopoJSON using Mapshaper’s in-browser API:

const output = await mapshaper.applyCommands(
'-i input.geojson -o output.topojson format=topojson',
{ 'input.geojson': JSON.stringify(featureCollection) }
);
const topology = JSON.parse(output['output.topojson']);

Mapshaper is loaded as a global (window.mapshaper) from the same pre-built static bundle that powers the simplification worker.


runLayerPipeline(id, applyDefaults) runs two async stages sequentially. Both stages run in web workers and communicate via a pending-request registry keyed by requestId.

SMART STAGE INVALIDATION — updateLayerProcessing()

Simplification key changed Chaikin key changed Bezier key changed Stage 1: Mapshaper simplify.worker.js Stage 2: Chaikin geo.worker.ts workingTopologyData ready · hasTopology = true Stage 1 skipped Stage 2: Chaikin geo.worker.ts workingTopologyData ready · hasTopology = true no topology work bezierCacheKey++ on the layer object path cache rebuild new bezier params only

Reads rawTopologyData, writes simplifiedTopologyData.

If applyDefaults is true and the layer has more than 500,000 coordinate points, Graticule auto-enables simplification at 90% tolerance and shows a toast notification. This keeps large datasets from freezing the renderer on first load.

Reads simplifiedTopologyData, writes workingTopologyData.

Chaikin is applied in topology space — on the shared arc coordinates directly, not on GeoJSON features. This means shared borders between adjacent countries are smoothed exactly once, and the result is consistent on both sides with no gaps. The open-line variant pins the first and last point of each arc (junction nodes where multiple borders meet) so borders don’t drift apart after smoothing.

If Chaikin is disabled, workingTopologyData is a reference alias to simplifiedTopologyData — no data is duplicated.

After Stage 2, geometry types are detected from the topology structure, layer.geometryTypes is set, and layer.hasTopology = true fires, triggering the canvas cache builder.


Before projecting geometry to screen coordinates, the geo worker splits each layer’s features into render chunks. Each chunk becomes a separate Path2D with its own axis-aligned bounding box, enabling per-chunk viewport culling during paint.

The vertex budget per chunk is 50,000. This keeps individual ctx.fill() and ctx.stroke() calls fast while producing tight enough bounding boxes to cull effectively.

Polygon layers — BFS over the adjacency graph

Section titled “Polygon layers — BFS over the adjacency graph”

For polygon layers, the worker builds an adjacency graph from the TopoJSON arc structure. Two features sharing an arc index are adjacent. Chunks are then built using BFS seeded in Hilbert curve order:

Sort features by Hilbert key (centroid → 1D space-filling index)
While unassigned features remain:
Pick lowest Hilbert key as seed
BFS-expand through adjacency graph until vertex budget is reached
Emit chunk

Hilbert-seeded BFS produces geographically compact chunks — neighboring countries or states end up together. Compact chunks have tighter bounding boxes, which makes viewport culling more effective than arbitrary ID-order batching.

When there’s no topology adjacency (uploaded GeoJSON rivers, roads, point sets), the worker falls back to a Hilbert curve sort followed by greedy vertex-budget batching. Spatially nearby features still cluster together even without explicit adjacency data.

Chunk groups depend on topology structure, not projection or globe rotation. They’re cached in the worker’s internal chunkGroupsCache by layer ID. On a rotation-only rebuild — the common case during globe drag — the worker skips the BFS/Hilbert step entirely and re-projects the existing groups to the new screen coordinates.


MapCanvas.svelte contains two separate $effects that keep concerns cleanly separated.

CACHE BUILDER $effect (runs when cacheSignal changes) topology changed? workerStoreTopology() workerBuildPaths() dispatched to geo.worker chunks: PathCommand[] + bbox per chunk pathCache updated cacheVersion++ PAINT $effect (runs when cacheVersion bumps) iterate layers bottom → top order viewport cull by bbox skip off-screen chunks ctx.fill / ctx.stroke stamp Path2D onto canvas canvas repainted no geometry work done

Watches cacheSignal — a string fingerprint of all layer.id:layer.hasTopology:layer.bezierCacheKey values, sorted so layer reordering doesn’t trigger it. When the fingerprint changes, the effect:

  1. Detects if the projection changed (type or rotation). If so, marks all cached paths as stale — old paths stay visible during rebuild instead of flashing invisible.
  2. Sends new or changed topologies to the geo worker via workerStoreTopology() — fire-and-forget. The worker stores the topology internally and reuses it for every subsequent BUILD_PATHS request without re-transfer.
  3. Dispatches workerBuildPaths() for each layer needing a fresh path. Responses write to pathCache and bump cacheVersion.

Layers already in inFlightBuilds are skipped — no duplicate in-flight requests. A pathBuildEpoch counter detects projection-type changes mid-flight: if the epoch has advanced when a worker response arrives, the result is discarded. For globe drag, a ROTATE_BUILD_THRESHOLD of 2° gates path rebuilds so the first tiny pointer movement doesn’t dispatch a full rebuild.

Watches cacheVersion. No geometry computation happens here — just stamping pre-built Path2D objects onto the canvas with current styles. The loop clears the canvas, draws the globe sphere disk, atmospheric halo, drop shadow, and graticule grid if enabled, then iterates layers bottom-to-top, culling each chunk against the current viewport bounds before calling ctx.fill() / ctx.stroke().

Point features are not chunked — they’re drawn by projecting each feature’s coordinates individually and stamping a d3-shape symbol path.


When bezier is enabled, the geo worker takes a completely different path. Instead of converting topology to GeoJSON and chunking features, it:

  1. Decodes every arc from TopoJSON delta-encoding to absolute geographic coordinates.
  2. Projects each arc point to screen space using the current projection.
  3. Computes cubic Bézier control points for each segment using the chosen algorithm (Catmull-Rom, Kochanek-Bartels, or B-spline).
  4. Returns the entire layer as a single chunk with a sentinel bounding box of [-Infinity, -Infinity, Infinity, Infinity] — never viewport-culled.

Because bezier arcs are computed in screen space after projection, the visual curves change as you zoom or change projection. Bezier is purely a display treatment. GeoJSON, Shapefile, and TopoJSON exports contain the original simplified/smoothed vertices — only SVG and PNG output show the bezier interpolation.

The arc-level projection requires careful handling at globe boundaries. A clipDistRad threshold filters back-hemisphere points before projection. An antimeridian detection step (|lon_i - lon_{i+1}| > 180°) emits moveTo break commands instead of drawing Bézier arcs across the seam. Ghost endpoint reflection prevents tangent blow-out at arc endpoints and near polar distortion zones.


history.svelte.ts implements a 50-entry undo/redo stack using JSON snapshots.

A Snapshot captures the layers array metadata (id, name, visible, style, processing settings, hasTopology), the current projection ID, and the canvas background color. It does not capture topology data — snapshots are small because they store only the settings needed to recreate the topology, not the geometry itself.

restore(snapshot) splices the layers array to match the snapshot (with hasTopology: false to force path cache invalidation), then calls runLayerPipeline(id, false) for each layer that had data — re-running Mapshaper and Chaikin with the restored processing settings. Undo is therefore asynchronous: the layer shows loading: true until the pipeline completes.


project.ts serializes the current session to a plain .json file.

Saving captures each layer’s metadata, style, processing settings, and whether it’s a catalog layer or upload, plus the projection ID. Uploaded dataset topologies are embedded inline in full.

Loading clears the current session, restores the projection, re-populates uploaded datasets from the inline topology data, then:

  • For uploaded layers: calls addUploadedLayer() directly with the stored topology.
  • For catalog layers: re-fetches the TopoJSON from the CDN using the stored datasetId, then runs the pipeline with applyDefaults: false so saved styles aren’t overwritten.

Uploaded topologies are stored inline, so large uploads make the project file large. A 50 MB warning is shown during save.


All data is stored in WGS84 (EPSG:4326). Projections come from d3-geo and d3-geo-projection. Two interaction modes: pan for flat projections (tx/ty offsets + pointer-position zoom) and rotate for globe projections (projectionStore.rotate updated on drag).

A coordinate clamp is applied before any projection math runs, in both the main thread and the geo worker:

s.point(
Math.max(-180, Math.min(180, lon)),
Math.max(-90, Math.min( 90, lat))
);

TopoJSON’s integer quantization can produce coordinates like lat = 90 + ε from floating-point rounding in the scale/translate arithmetic. Mercator returns NaN for |lat| > 90, which causes Canvas 2D to produce triangular fill artifacts. The clamp is invisible on screen but eliminates the problem entirely.


Plain Maps for topology, not reactive state. Svelte 5’s reactive proxy traverses every nested value on read. Geographic data is too large to survive this — a single dataset can have millions of coordinate pairs. The reactive signal is layer.hasTopology, not the topology itself.

Topology as the internal format. All geometry — catalog and uploaded — lives in TopoJSON inside Graticule. TopoJSON’s shared-arc representation is what makes topology-aware simplification and Chaikin smoothing possible without gaps at shared borders. Non-TopoJSON uploads go through Mapshaper’s applyCommands to convert before entering the pipeline.

Two workers, not one. Mapshaper is a large, self-contained bundle that must be served as a pre-built static file. Vite can’t bundle it normally. The geo worker handles everything else and is built by Vite as a regular module worker. Keeping them separate avoids an enormous main worker bundle.

Chunk groups cached by topology, not projection. Geographic adjacency and Hilbert sort order don’t change with projection or rotation. Caching group assignments means a globe drag — which triggers many rebuilds per second — only re-runs the projection step, not the chunking step.

Stale paths stay visible during rebuild. When the projection changes, existing cached paths are marked stale but kept in the cache. The paint effect continues drawing them until fresh paths arrive. This eliminates the flash of invisible layers on projection changes and globe rotation.

History snapshots, not topology copies. Snapshots are small enough to keep 50 entries in memory. Restoring re-runs the pipeline, which means undo for large datasets takes a moment — but the history stack is bounded regardless of dataset size.