Skip to content
Tikab's Toolkit

Debug & GIS tools

@repo/debug is developer tooling for the GIS layer: SWEREF 99 coordinate transforms that prove themselves, and a Sweden map that needs no tile server.

Swedish geodata lives in SWEREF 99 — the national projection SWEREF 99 TM plus twelve local zones. GPS speaks WGS84. Anything that puts data on a map crosses that boundary, and a swapped axis or a wrong zone parameter produces coordinates that look plausible and are hundreds of meters off. So this package treats verifiability as the feature:

  • sweref99.ts — proj4 definitions for TM + all twelve zones, with transforms that take and return named fields (lat/lng, northing/easting). proj4 itself speaks positional [x, y], which is exactly where the classic axis-swap bug lives.
  • control-points.tsLantmäteriet's official control points (published precisely so software can check itself) and verifyProjections(), which runs every point through proj4 in both directions and reports deviations in meters.
  • MapView — a real interactive map (MapLibre GL). The tile source is a prop: OSM raster tiles by default for dev, your own self-hosted style (e.g. pmtiles) in an air-gapped deployment.

Try it

The verification below ran in your browser when the page loaded — 88 official control points across the national projection and all twelve local zones:

Lantmäteriet control points: 88 checks · all OK · max deviation 0.50 mm — verified in your browser just now

Stockholm

WGS84: 59.3293, 18.0686

SWEREF 99 TM: N 6580743.0, E 674571.9

Click a marker — the panel recomputes through proj4. The map is MapLibre GL on OSM tiles; an air-gapped deployment passes its own tile style as a prop.

The transforms

/** Geodetic coordinates (what GPS speaks). */
export interface Wgs84 {
  lat: number;
  lng: number;
}
 
/** Projected plane coordinates in meters. */
export interface SwerefCoord {
  northing: number;
  easting: number;
}
 
/**
 * WGS84 → SWEREF 99. Named fields on purpose: proj4 itself speaks
 * positional `[x, y]` = `[lng, lat]` = `[easting, northing]`, and swapped
 * axes are the classic GIS bug — this API makes the order unmistakable.
 */
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! };
}
 
/** SWEREF 99 → WGS84. */
export function swerefToWgs84(c: SwerefCoord, zone: SwerefZone = SWEREF99_TM): Wgs84 {
  const [lng, lat] = proj4(zone.proj4, WGS84, [c.easting, c.northing]);
  return { lat: lat!, lng: lng! };
}

The self-test

verifyProjections() is the "how do we KNOW the transforms are right" answer — the sandbox renders it, the e2e spec asserts it, and any wrong ellipsoid, zone parameter or axis order fails loudly:

/** Approximate meters per degree at a latitude — for round-trip error only. */
function degreesToMeters(dLat: number, dLng: number, lat: number): number {
  const mLat = dLat * 111_320;
  const mLng = dLng * 111_320 * Math.cos((lat * Math.PI) / 180);
  return Math.hypot(mLat, mLng);
}
 
function checkPoint(zone: SwerefZone, point: ControlPoint, toleranceM: number): ProjectionCheck {
  const computed = wgs84ToSweref({ lat: point.lat, lng: point.lng }, zone);
  const back = swerefToWgs84(computed, zone);
  const deltaNorthingM = computed.northing - point.northing;
  const deltaEastingM = computed.easting - point.easting;
  const roundTripM = degreesToMeters(back.lat - point.lat, back.lng - point.lng, point.lat);
  return {
    zone,
    point,
    computed,
    deltaNorthingM,
    deltaEastingM,
    roundTripM,
    ok:
      Math.abs(deltaNorthingM) <= toleranceM &&
      Math.abs(deltaEastingM) <= toleranceM &&
      roundTripM <= toleranceM,
  };
}
 
/**
 * Run proj4 against every official Lantmäteriet control point — SWEREF 99 TM
 * and all twelve local zones — both directions. Default tolerance 1 mm
 * (Lantmäteriet publishes the points to the millimeter).
 */
export function verifyProjections(toleranceM = 0.001): {
  checks: ProjectionCheck[];
  allOk: boolean;
  maxAbsDeltaM: number;
} {
  const checks: ProjectionCheck[] = TM_CONTROL_POINTS.map((p) =>
    checkPoint(SWEREF99_TM, p, toleranceM),
  );
  for (const zone of SWEREF99_ZONES) {
    const key = zone.name.replace("SWEREF 99 ", "");
    for (const p of ZONE_CONTROL_POINTS[key] ?? []) {
      checks.push(checkPoint(zone, p, toleranceM));
    }
  }
  const maxAbsDeltaM = Math.max(
    ...checks.flatMap((c) => [Math.abs(c.deltaNorthingM), Math.abs(c.deltaEastingM), c.roundTripM]),
  );
  return { checks, allOk: checks.every((c) => c.ok), maxAbsDeltaM };
}

Current result: max deviation ≈ 0.5 mm against Lantmäteriet's published millimeter-precision values.

The map

MapView is string-free pure UI like every other UI block — markers, labels and aria text come from the consumer. The map itself speaks WGS84 (like every slippy map); SWEREF 99 happens at the seams through the verified transforms:

export function MapView({
  markers,
  selectedId,
  onMapClick,
  onMarkerClick,
  ariaLabel,
  mapStyle = OSM_RASTER_STYLE,
  bounds = SWEDEN_BOUNDS,
  testId,
  className,
}: {
  markers: MapMarker[];
  selectedId?: string | number;
  /** Click anywhere on the map — WGS84; project to SWEREF 99 as needed. */
  onMapClick?: (point: Wgs84) => void;
  onMarkerClick?: (id: string | number) => void;
  /** Accessible name for the map region — consumer i18n. */
  ariaLabel: string;
  /** MapLibre style or style URL — override for self-hosted/air-gapped tiles. */
  mapStyle?: StyleSpecification | string;
  /** Initial camera as [[west, south], [east, north]]. */
  bounds?: [[number, number], [number, number]];
  testId?: string;
  className?: string;
}) {

Clicks hand the consumer a WGS84 point; /sandbox/map projects it to SWEREF 99 TM for display and stores it as a PostGIS point on the project.

Where to see it in the repo

  • /sandbox/map — control points verified live, projects placed on the map (stored as PostGIS points on the project row). Spec: e2e/sandbox/map.spec.ts — it recomputes Stockholm's SWEREF 99 TM coordinates independently and asserts the UI shows the same numbers.
  • Database → Geodata for the PostGIS side of the same story.