Skip to content
Fusion

GIS

fusion-gis is the stack's geospatial seam for Swedish data. It has two halves that never have to meet:

  • sweref99 — pure, isomorphic proj4 transforms between WGS84 and SWEREF 99 (Sweden's national reference frame). No MapLibre, so it runs on the server too.
  • MapView — a real interactive map, a thin React wrapper around MapLibre GL. The tile source is a prop, so an air-gapped deployment points it at self-hosted tiles instead of the public internet.
Loading diagram...

Coming from Django (GeoDjango)? The mapping is almost one-to-one. PostGIS is the same django.contrib.gis spatial database you already use. The sweref99 transforms are GEOSGeometry.transform(srid) / SpatialReference — proj4 doing the projection math, just in TypeScript so it also runs in the browser. MapView is the Leaflet/Mapbox widget your admin embeds, except it's MapLibre GL (the open-source fork of Mapbox GL) and the basemap is a prop. There is no gis app, no GeometryField form widget, no geodjango settings — the seam is four exports and a component.

Import the barrel for the map (@tikab-interactive/fusion-gis), or import @tikab-interactive/fusion-gis/sweref99 directly when you only need the server-safe transforms — that path never pulls MapLibre into the bundle.

Two coordinate systems, and why

Web maps and Swedish source data disagree about how to name a point on Earth, and the whole package exists to mediate that.

  • WGS84 ({ lat, lng }, decimal degrees) is what GPS emits and what every slippy map speaks. MapLibre, Leaflet, Google Maps — all of them want longitude/latitude on the WGS84 datum, projected to Web Mercator for display. This is the lingua franca of the browser.
  • SWEREF 99 ({ northing, easting }, meters) is the planar national grid Swedish authorities actually store. It is a Transverse Mercator projection, not a datum — coordinates are meters on a flat plane, which is what surveyors, cadastral registers (Lantmäteriet) and construction CAD want, because distances and areas are just subtraction. A point near Stockholm is something like { northing: 6_580_822, easting: 674_032 }.

The job of fusion-gis is to convert between the two without ever swapping the axes, which is the single most common GIS bug.

Coming from Django? SWEREF 99 TM is SRID 3006; the twelve local zones are SRID 3007–3018; WGS84 is the familiar SRID 4326. wgs84ToSweref is point.transform(3006). The reason you'd want SWEREF at all is the same reason GeoDjango lets you set a non-4326 SRID on a column: distance/area math in meters on a projected grid beats trigonometry on a sphere.

What this stack actually stores

A subtlety worth stating plainly, because it changes where the transform runs: in the example app the project's location is stored as WGS84 already — two plain double precision columns, not a SWEREF grid or even a PostGIS geometry. See processnProject in pulsn-apps/src/processn/schema.ts:

// Real WGS84 coordinates (the project's physical location) — drives the portfolio + map.
latitude: doublePrecision("latitude"),
longitude: doublePrecision("longitude"),

So the read path needs no transform: the server hands { lat, lng } straight to MapView as markers. The sweref99 transform earns its keep on the other direction — when a user clicks the map (WGS84) and you want to record or display a SWEREF 99 grid reference, or when you ingest data that arrives as SWEREF and must be projected to WGS84 before it can be a marker. That's why the demo (and the mermaid above) shows wgs84ToSweref on the click path, not on the storage path.

PostGIS is enabled and ready (see below) for the moment a real geometry/geography column, a spatial index, or a ST_DWithin proximity query is needed — but the portfolio map deliberately stays on two scalar columns until that day, because it doesn't need spatial SQL to drop a pin.

PostGIS

PostGIS is the Postgres extension that turns the database into a spatial one: new column types (geometry, geography), spatial indexes (GiST), and a few hundred ST_* functions (ST_Distance, ST_Within, ST_Transform, …). It is the exact same engine that backs django.contrib.gis.

Coming from Django (GeoDjango)? This is GeoDjango's database half. The python manage.py migrate step that runs CreateExtension('postgis') is, here, a hand-written Drizzle SQL migration. A GeoDjango PointField(srid=3006) would become a PostGIS geometry(Point, 3006) column; ST_Transform is wgs84ToSweref done in SQL instead of in JS. The difference is only where the projection runs.

Turning it on: the CREATE EXTENSION migration

A Postgres extension exists in the image but is dormant until a migration switches it on, per-database. drizzle-kit can't express CREATE EXTENSION, so it's a hand-written SQL migration — example/drizzle/0001_enable_postgis.sql:

-- Enable PostGIS. The image (postgis/postgis locally, Azure Flexible Server with
-- `azure.extensions=POSTGIS`) ships the extension; this turns it on so spatial
-- columns (geometry/geography) and functions are available.
CREATE EXTENSION IF NOT EXISTS postgis;

IF NOT EXISTS makes it idempotent — re-running migrations against an already-enabled database is a no-op. The same pattern enables the other extensions the stack uses, each in its own migration: vector (pgvector, for the agent's embeddings) and pg_trgm (for trigram search).

The arm64-safe image

The official postgis/postgis image is amd64-only, so it runs under emulation on Apple Silicon — slow, and a frequent source of "works on CI, crawls on my Mac." Local dev uses the multi-arch imresamu/postgis build instead, with pgvector layered on top in example/docker/postgres.Dockerfile:

# Local Postgres for the Fusion example: PostGIS + pgvector in one multi-arch image.
# Based on imresamu/postgis (arm64 + amd64). pgvector backs the proactive-agent
# memory (the vector(768) columns); PostGIS backs the GIS sandbox. Built once and
# vendorable into the air-gapped .tar bundle — no internet needed at deploy time.
FROM imresamu/postgis:17-3.5
RUN apt-get update \
	&& apt-get install -y --no-install-recommends postgresql-17-pgvector \
	&& rm -rf /var/lib/apt/lists/*

Building it once means the image is vendorable: docker save it into the air-gapped .tar bundle (see Deploy) and no registry pull is needed at the classified site.

The Azure gotcha: the azure.extensions allow-list

This is the one that bites. Azure Database for PostgreSQL Flexible Server refuses CREATE EXTENSION for anything not on a server-level allow-list — even if the binary is present. Miss one, and the migration step fails with a permission-ish error and the deploy crash-loops. So every extension any migration creates must also be listed in Bicep. From example/infra/main.bicep:

resource pgExtensions 'Microsoft.DBforPostgreSQL/flexibleServers/configurations@2024-08-01' = {
  parent: pg
  name: 'azure.extensions'
  properties: {
    // Hatchet's own migrations create `btree_gist` on its database (for a GIST
    // exclusion constraint) — verified as the only non-default extension in a fully
    // migrated Hatchet DB. Allow-list it when the Hatchet stack is deployed, else Azure
    // rejects `CREATE EXTENSION btree_gist` and the engine's migration crash-loops.
    value: deployHatchet ? 'POSTGIS,VECTOR,PG_TRGM,BTREE_GIST' : 'POSTGIS,VECTOR,PG_TRGM'
    source: 'user-override'
  }
}

The rule of thumb: when you add a CREATE EXTENSION foo migration, add FOO to this value in the same change, or the next Azure deploy breaks. (drizzle hides the underlying error, so the symptom is a generic migrate failure — this list is the first place to look.) The names are case-insensitive but listed upper-case to match Azure's docs.

The map: MapView

MapView is a real interactive MapLibre GL map — not a hand-rolled SVG of Sweden. It owns exactly one thing (the map instance); everything else — markers, labels, the accessible name — comes from the consumer, so it inherits the app's i18n and Mantine theme. Marker chrome uses Mantine CSS variables, so selected/unselected colours follow the active theme automatically.

// the props MapView accepts (src/MapView.tsx)
export function MapView(props: {
	markers: MapMarker[]; // { id, label, lat, lng }
	selectedId?: string | number;
	onMapClick?: (point: Wgs84) => void; // click anywhere — WGS84
	onMarkerClick?: (id: string | number) => void;
	ariaLabel: string; // accessible name — consumer i18n
	mapStyle?: StyleSpecification | string; // tile source — override for self-hosted/air-gapped
	bounds?: [[number, number], [number, number]]; // initial camera; defaults to Sweden
	interactive?: boolean; // false = pre-framed, locked map (the inline conversational map)
	testId?: string;
	className?: string;
}): JSX.Element;

How it's built — once, imperatively

Inside MapView the map is not React-rendered. The component renders a single empty <section ref={containerRef}> and an effect with [] dependencies news up a maplibregl.Map into it on mount and map.remove()s it on unmount. Two consequences are deliberate and worth understanding:

  • The map is built once. mapStyle, bounds and interactive are read through refs (mapStyleRef.current, …) at creation time, so changing those props after mount does nothing. That's why the init effect needs no dependencies and the map is never torn down and rebuilt on an unrelated re-render. If you need a different style, remount.
  • Callbacks stay fresh without re-binding listeners. onMapClick/onMarkerClick are kept in refs that are updated every render (onMapClickRef.current = onMapClick), and the single map.on("click", …) listener (bound once) reads .current. So you get fresh closures without ever re-attaching handlers.

Markers are the one thing that does reconcile: a second effect ([markers, selectedId]) diff-syncs them. Each marker is a real <button> DOM element handed to new maplibregl.Marker({ element }) — clickable, theme-able via inline styles bound to Mantine variables, and data-testid-friendly. New ids get created, missing ids get .remove()d, and the selected one swaps to --mantine-primary-color-filled.

Two things are therefore not live props: the map is built once (above), and clicks report WGS84 — project them to SWEREF 99 yourself with wgs84ToSweref so the axis order is unmistakable.

Browser-only — load it lazily

MapView imports maplibre-gl and its CSS at module load, so it must never reach the SSR bundle (MapLibre touches window/document at import time). The discipline across the example is the same everywhere: the map component is the default export of its own module, that module is pulled with lazy(() => import(...)), and it's only rendered after a mounted flag flips in useEffect — so the lazy chunk is fetched client-side and MapLibre is never evaluated during render-to-string. The pure sweref99 transform, by contrast, is imported directly and runs anywhere.

example/src/routes/·.gis.tsx — the GIS demo route
import { createFileRoute } from "@tanstack/react-router";
import { Suspense, lazy, useEffect, useState } from "react";
 
import { Code, Skeleton, Stack, Text } from "@tikab-interactive/fusion-ui/mantine";
import { PageHeader } from "@tikab-interactive/fusion-ui";
 
import { type Wgs84, wgs84ToSweref } from "@tikab-interactive/fusion-gis/sweref99";
 
import { m } from "#/paraglide/messages.js";
 
// The map (fusion-gis MapView → MapLibre) is browser-only, so it's lazy-loaded
// and rendered only after mount — maplibre never hits SSR. The SWEREF 99
// transform (`wgs84ToSweref`, pure proj4) IS server-safe, so it's imported
// directly and runs wherever.
const GisMap = lazy(() => import("#/components/GisMap"));
 
export const Route = createFileRoute("/sandbox/gis")({
	component: GisSandboxPage,
});
 
function GisSandboxPage() {
	const [mounted, setMounted] = useState(false);
	const [point, setPoint] = useState<Wgs84 | null>(null);
	useEffect(() => setMounted(true), []);
 
	const sweref = point ? wgs84ToSweref(point) : null;
 
	return (
		<Stack>
			<PageHeader
				eyebrow={m.sandbox_eyebrow()}
				title={m.gis_sandbox_title()}
				description={m.gis_sandbox_description()}
			/>
 
			{mounted ? (
				<Suspense fallback={<Skeleton className="gis-sandbox-map" />}>
					<GisMap ariaLabel={m.gis_map_aria()} onPoint={setPoint} />
				</Suspense>
			) : (
				<Skeleton className="gis-sandbox-map" />
			)}
 
			{point && sweref ? (
				<Stack gap={4}>
					<Text size="sm">
						<b>{m.gis_wgs84_label()}:</b>{" "}
						<Code>
							{point.lat.toFixed(5)}, {point.lng.toFixed(5)}
						</Code>
					</Text>
					<Text size="sm">
						<b>{m.gis_sweref_label()}:</b>{" "}
						<Code>
							{sweref.northing.toFixed(2)}, {sweref.easting.toFixed(2)}
						</Code>
					</Text>
				</Stack>
			) : (
				<Text c="dimmed" size="sm">
					{m.gis_click_hint()}
				</Text>
			)}
		</Stack>
	);
}

Coming from React? The instinct is to render <MapView> directly and reach for next/dynamic-style tricks only if SSR complains. Here it's the default: any component that transitively imports maplibre-gl is a default-exported, lazily-imported, mount-gated module. Treat it like an effect, not like markup.

The map in the example app

The same MapView shows up in three postures, which together cover most product needs.

Portfolio + per-project pins. ProjectMap (example/src/components/ProjectMap.tsx) turns a list of { key, name, latitude, longitude } projects into markers and computes a camera box around them — tight padding (0.04°) for a single building, wide (0.5°) for the whole portfolio. Its wrapper ProjectMapPanel does the lazy + mounted dance and renders a <Skeleton> until the map chunk lands. Because the DB already stores WGS84, the marker mapping is a straight field copy — no transform:

example/src/components/ProjectMap.tsx
	const markers: MapMarker[] = pins.map((p) => ({
		id: p.key,
		label: p.name,
		lat: p.latitude,
		lng: p.longitude,
	}));

Carola can put projects on a map. The agent exposes a show_project_map tool (example/src/lib/agent-assistant.ts) — ask "show my projects on a map" / "visa på kartan" and it calls getPortfolio(), filters to buildings with coordinates, and returns { map: { markers } }. The chat renderer mounts an inline map for that tool result instead of listing coordinates in prose:

example/src/lib/agent-assistant.ts
	const showMap = toolDefinition({
		name: "show_project_map",
		description:
			"Show the user's projects (buildings) on a small inline map. Use when they ask to see projects on a map, where a building is, or 'visa på kartan'. Returns map markers — the UI renders them as an inline map, so do NOT also list the coordinates in prose.",
		inputSchema: z.object({}),
	}).server(async () => {
		const buildings = await getPortfolio();
		const markers = buildings
			.filter((b) => b.latitude != null && b.longitude != null)
			.map((b) => ({
				id: b.key,
				label: b.name,
				lat: b.latitude as number,
				lng: b.longitude as number,
			}));
		return { map: { markers } };
	});

The locked inline map. That conversational map uses MapView's interactive={false} (example/src/components/InlineMapView.tsx). Passing interactive: false to MapLibre disables every camera handler at once — drag-pan, scroll-zoom, box-zoom, rotate, keyboard, double-click-zoom — while markers stay clickable. Carola has already framed the map (it set bounds); the only affordance left is tapping a pin, which drops a gestural follow-up turn back into the chat.

Coming from React? Note what isn't here: no map component renders coordinates as text, and the portfolio map and the in-chat map are the same MapView with different props (interactive, bounds). The component is the renderer; product meaning lives in the props the route/agent passes.

Overriding the tile source

The default mapStyle is OSM_RASTER_STYLE — OpenStreetMap's public raster tiles, fine for development (and carrying OSM's required attribution string, which is their credit, not app copy). To use any other basemap, pass a MapLibre style object or a style URL:

import { MapView } from "@tikab-interactive/fusion-gis/MapView";
import type { StyleSpecification } from "maplibre-gl";
 
// A hosted vector style (token-gated CDN, your own tile server, …):
<MapView ariaLabel="…" markers={markers} mapStyle="https://tiles.example.se/style.json" />;
 
// …or an inline style you assemble yourself:
const style: StyleSpecification = {
	version: 8,
	sources: {
		basemap: { type: "raster", tiles: ["https://tiles.example.se/{z}/{x}/{y}.png"], tileSize: 256 },
	},
	layers: [{ id: "basemap", type: "raster", source: "basemap" }],
};
<MapView ariaLabel="…" markers={markers} mapStyle={style} />;

Because the style is read once at creation, this is a deploy-time choice, not a runtime toggle — exactly what makes the next section possible with the same component.

Air-gapped: self-hosted PMTiles

At Riksdagsförvaltningen (and any other air-gapped delivery) the map cannot reach tile.openstreetmap.org. The fix is to ship the basemap inside the deployment as a single PMTiles archive — one file, served over plain HTTP range requests, no tile server process.

  1. Build the archive once (outside the air gap) and bake it into the image or a mounted volume. Clip it to the area you actually need so it stays small:

    # Extract just Sweden from a planet basemap into one .pmtiles file.
    pmtiles extract https://build.protomaps.com/planet.pmtiles sweden.pmtiles \
      --bbox=10.5,55.0,24.2,69.1
  2. Register the PMTiles protocol with MapLibre before the map mounts — this is what teaches MapLibre to read pmtiles://… URLs:

    // client-only setup, e.g. at the top of the lazy GisMap module
    import maplibregl from "maplibre-gl";
    import { Protocol } from "pmtiles";
     
    const protocol = new Protocol();
    maplibregl.addProtocol("pmtiles", protocol.tile);
  3. Point mapStyle at the bundled archive — served from your own origin, so no request ever leaves the network:

    const airGappedStyle: StyleSpecification = {
    	version: 8,
    	// Served by the app/static host inside the air gap — adjust the path to taste.
    	sources: { sweden: { type: "vector", url: "pmtiles:///tiles/sweden.pmtiles" } },
    	layers: [
    		/* background + your land/water/road layers referencing source "sweden" */
    	],
    };
     
    <MapView ariaLabel="Karta över Sverige" markers={markers} mapStyle={airGappedStyle} />;

pmtiles is a consumer dependency (it isn't bundled by fusion-gis) — add it to the app that wires the protocol. Because the tile source is just a prop, the same MapView component serves the public-internet dev build and the air-gapped production build unchanged.

SWEREF 99 transforms

Swedish geometry data is almost always SWEREF 99 TM (EPSG:3006) — UTM zone 33 on GRS80 — or one of the twelve local zones (EPSG:3007–3018) used where scale distortion must stay tiny (construction, cadastral work). The transforms use named fields on purpose: proj4 itself speaks positional [x, y] = [lng, lat] = [easting, northing], and swapped axes are the classic GIS bug.

import {
	swerefToWgs84,
	SWEREF99_ZONES,
	wgs84ToSweref,
} from "@tikab-interactive/fusion-gis/sweref99";
 
// Defaults to SWEREF 99 TM (the national projection).
const plane = wgs84ToSweref({ lat: 59.3293, lng: 18.0686 }); // → { northing, easting }
const back = swerefToWgs84(plane); // → { lat, lng }
 
// Pass a local zone when the data is zoned (e.g. Stockholm → SWEREF 99 18 00):
const zone = SWEREF99_ZONES.find((z) => z.epsg === "EPSG:3014")!;
const local = wgs84ToSweref({ lat: 59.3293, lng: 18.0686 }, zone);

How the transform works

Each SwerefZone carries a proj4 definition string — the projection spelled out as parameters. SWEREF 99 TM is literally UTM zone 33 on GRS80:

// src/sweref99.ts
const WGS84 = "+proj=longlat +datum=WGS84 +no_defs";
 
export const SWEREF99_TM: SwerefZone = {
	name: "SWEREF 99 TM",
	epsg: "EPSG:3006",
	centralMeridian: 15,
	proj4: "+proj=utm +zone=33 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs",
};

wgs84ToSweref and swerefToWgs84 are thin wrappers that hand those two definition strings to proj4 and — critically — re-label the positional result into named fields:

export function wgs84ToSweref(p: Wgs84, zone: SwerefZone = SWEREF99_TM): SwerefCoord {
	const [easting, northing] = proj4(WGS84, zone.proj4, [p.lng, p.lat]);
	return { northing: northing!, easting: easting! };
}

The +towgs84=0,0,0,0,0,0,0 is the load-bearing approximation: SWEREF 99 ≈ ETRS89 ≈ WGS84 at the precision that matters here, so the datum shift is the identity and only the projection (degrees → meters) is doing real work. That's why the round-trip is exact to the millimeter (next section), and why you can treat a SWEREF 99 latitude as a WGS84 latitude for display.

The twelve local zones are generated by a localZone() factory — same Transverse Mercator shape, scale 1, false easting 150 000 m, varying only by central meridian (12°00'E → 23°15'E). SWEREF99_ZONES is that array; SWEREF99_TM is the national default.

The self-test: verifyProjections()

Projection bugs are silent — an axis swap or a wrong ellipsoid still returns plausible-looking numbers. So the package ships Lantmäteriet's official control points (src/control-points.ts: 15 spanning Sweden for TM, plus per-zone sets) and a runtime self-test that proves the math:

import { verifyProjections } from "@tikab-interactive/fusion-gis";
 
const { allOk, maxAbsDeltaM } = verifyProjections(); // default tolerance: 1 mm
// allOk === true; maxAbsDeltaM is sub-millimeter

verifyProjections() runs every official point through proj4 both ways — forward to the published northing/easting, then back to lat/lng — and asserts both the projected delta and the round-trip distance are within tolerance (default 0.001 m, because Lantmäteriet publishes the points to the millimeter). Any axis swap, wrong ellipsoid, or wrong zone parameter fails loudly with the exact deviation in meters. It's run in the package's tests (src/control-points.test.ts); a consumer can call it as a startup smoke-check too.

Coming from Django? Think of verifyProjections() as a fixture-backed test of your SRID definitions — proof that the local proj4 strings reproduce the authority's numbers, the same confidence you'd get from PROJ/GDAL on the server, but it also runs in the browser bundle.

See also

  • Architecture — where fusion-gis sits in the stack.
  • Deploy — the air-gapped .tardocker compose delivery model.
  • Sandbox — the live GIS demo in the example app.
  • Coming from Django — views → server functions, models → Drizzle.