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.
Overall architecture
Section titled “Overall architecture”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 data model
Section titled “The data model”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}The three topology Maps
Section titled “The three topology Maps”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 readAll 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.
Data ingestion
Section titled “Data ingestion”Catalog datasets
Section titled “Catalog datasets”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.
File uploads
Section titled “File uploads”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.
The processing pipeline
Section titled “The processing pipeline”runLayerPipeline(id, applyDefaults) runs two async stages sequentially. Both stages run in web workers and communicate via a pending-request registry keyed by requestId.
Stage 1 — Simplification
Section titled “Stage 1 — Simplification”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.
Stage 2 — Chaikin smoothing
Section titled “Stage 2 — Chaikin smoothing”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.
Chunking strategy
Section titled “Chunking strategy”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 chunkHilbert-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.
Line and uploaded layers — Hilbert sort
Section titled “Line and uploaded layers — Hilbert sort”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 group caching
Section titled “Chunk group caching”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.
Canvas rendering
Section titled “Canvas rendering”MapCanvas.svelte contains two separate $effects that keep concerns cleanly separated.
The cache builder effect
Section titled “The cache builder effect”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:
- 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.
- 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 subsequentBUILD_PATHSrequest without re-transfer. - Dispatches
workerBuildPaths()for each layer needing a fresh path. Responses write topathCacheand bumpcacheVersion.
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.
The paint effect
Section titled “The paint effect”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.
Bezier curves
Section titled “Bezier curves”When bezier is enabled, the geo worker takes a completely different path. Instead of converting topology to GeoJSON and chunking features, it:
- Decodes every arc from TopoJSON delta-encoding to absolute geographic coordinates.
- Projects each arc point to screen space using the current projection.
- Computes cubic Bézier control points for each segment using the chosen algorithm (Catmull-Rom, Kochanek-Bartels, or B-spline).
- 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.
The history system
Section titled “The history system”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 save and load
Section titled “Project save and load”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 withapplyDefaults: falseso 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.
Coordinate system and clamping
Section titled “Coordinate system and clamping”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.
Key architectural decisions
Section titled “Key architectural decisions”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.