Skip to content

Map

Map

MapLibre or Mapbox with deck.gl layers that follow the theme. A themed stage, themed controls and token colors as RGBA.

<script>
  import { MapStage, MapControls, mapColors } from "@codeatlasdev/ui/map";
  import { ScatterplotLayer } from "@deck.gl/layers";

  const layers = $derived.by(() => {
    const c = mapColors(); // reactive: re-runs on scheme or preset change
    return [
      new ScatterplotLayer({ id: "pins", data, getPosition: (d) => d.xy, radiusUnits: "pixels", getRadius: 8, getFillColor: c.brand }),
    ];
  });
</script>

<MapStage styles={{ light: "…/light.json", dark: "…/dark.json" }} {layers} fit={bounds}>
  <MapControls />
</MapStage>

styles takes a URL or a style object. A URL (a hosted style.json) is fetched at runtime, so it needs network. For an app that must work offline, pass a style object with one background layer painted from the theme: { version: 8, sources: {}, layers: [{ id: "bg", type: "background", paint: { "background-color": rgbaCss(mapColors().surface) } }] }, built inside a $derived so it follows the scheme (see the demo below).

deck.gl sizes a ScatterplotLayer dot in meters by default (radiusUnits: "meters"), so getRadius: () => 500 is a speck on a country-wide map. For markers set radiusUnits: "pixels" and give getRadius pixel values (or keep meters and add radiusMinPixels so the dot never shrinks below a visible size). Use meters only when the circle is a real geographic area.

The map lives in the @codeatlasdev/ui/map subpath and every engine is an optional peer, so install only what you use: maplibre-gl (default, no token, ^5 or ^6) or mapbox-gl (engine="mapbox" and an accessToken), and for data layers @deck.gl/core plus the overlay of your engine: @deck.gl/maplibre for maplibre-gl, @deck.gl/mapbox for mapbox-gl. Pin all @deck.gl/* packages to the same minor (~9.4): mixing @luma.gl/core 9.3 and 9.4 breaks the overlay at install time. For example bun add maplibre-gl @deck.gl/core@~9.4 @deck.gl/maplibre@~9.4 @deck.gl/layers@~9.4. Use maplibre-gl 6.4.1 or later: 6.4.0 and earlier have a zero-click XSS in the attribution sanitizer (GHSA-jrc7-96c5-q579, only reachable when a style carries untrusted attribution text).

maplibre-gl 6 ships its worker as a separate module that a bundler cannot locate, so the app wires it once before the first map, with the snippet from MapLibre’s installation guide (Vite shown; other bundlers have their own query): import { setWorkerUrl } from "maplibre-gl"; import workerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url"; setWorkerUrl(workerUrl);. Without it every map logs Worker failed to load and stays blank. maplibre-gl 5 needs nothing. Nothing is imported on the server: the engines load inside onMount, so MapStage and MapControls render in SSR (an empty stage with disabled controls) and hydrate on the client. In Astro, use client:visible.

MapStage keeps the basemap in step with the theme: it passes styles.light or styles.dark for colorScheme.resolved and, when the scheme changes, calls map.setStyle on the same map. The camera, the deck.gl overlay and your event handlers survive. The overlay is deck.gl’s MapLibreOverlay (MapboxOverlay for engine="mapbox") with interleaved: true, so layers sit between the basemap’s labels like native ones, and replacing the layers array updates it. fit runs fitBounds with padding, the pixels your UI covers on each side. Behind a PageFrame you pass nothing: each side defaults to stageInsets.current and the map fits again when the frame moves. Under prefers-reduced-motion, flyTo becomes jumpTo and fit does not animate.

Colors for deck.gl

deck.gl takes [r, g, b, a] and MapLibre paint wants rgba(), but the tokens are oklch() and color-mix(). tokenColor(token, alpha?) resolves any token or CSS color through a 1×1 canvas and caches by the resolved value, so a theme change needs no manual cache reset. mapColors() returns the semantic set, and reading it inside $derived or $effect subscribes to the scheme, preset and surface style. rgbaCss(color) turns a result into a string for paint properties.

Themed map

An offline basemap per scheme, deck.gl circles in the brand color and themed controls. Toggle the theme: the basemap and the circles change, the map is not recreated.

Code
svelte
<script lang="ts">
  import { ScatterplotLayer } from '@deck.gl/layers';
  import { setWorkerUrl } from 'maplibre-gl';
  import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url';
  import { MapControls, MapStage, mapColors, rgbaCss } from '@codeatlasdev/ui/map';

  // maplibre-gl 6 loads its worker from a separate file a bundler cannot locate: wire it once.
  setWorkerUrl(workerUrl);

  // Offline basemap: one background layer painted with the surface token, so no tiles, no token and
  // no hex. `mapColors()` reads the theme, so the style is rebuilt when the scheme or preset changes.
  const styles = $derived.by(() => {
    const background = rgbaCss(mapColors().surface);
    const style = {
      version: 8 as const,
      sources: {},
      layers: [{ id: 'bg', type: 'background' as const, paint: { 'background-color': background } }],
    };
    return { light: style, dark: style };
  });

  const stores = [
    { name: 'Sao Paulo', coordinates: [-46.63, -23.55], sales: 90 },
    { name: 'Rio', coordinates: [-43.17, -22.91], sales: 70 },
    { name: 'Belo Horizonte', coordinates: [-43.94, -19.92], sales: 45 },
    { name: 'Curitiba', coordinates: [-49.27, -25.43], sales: 38 },
  ];

  // `mapColors()` reads the theme, so this re-runs when the scheme or preset changes.
  const layers = $derived.by(() => {
    const c = mapColors();
    return [
      new ScatterplotLayer({
        id: 'stores',
        data: stores,
        getPosition: (d: (typeof stores)[number]) => d.coordinates as [number, number],
        getRadius: (d: (typeof stores)[number]) => d.sales * 600,
        getFillColor: c.brandMuted,
        getLineColor: c.brand,
        stroked: true,
        lineWidthMinPixels: 2,
      }),
    ];
  });
</script>

<div class="stage">
  <MapStage
    {styles}
    {layers}
    label="Store map"
    fit={[[-50, -26], [-42, -19]]}
    padding={{ top: 24, right: 24, bottom: 24, left: 24 }}
  >
    <MapControls />
  </MapStage>
</div>

<style>
  .stage { width: 100%; height: 360px; border-radius: var(--ca-radius-lg); overflow: hidden; }
</style>

Props

PropTypeDefaultDescription
styles{ light: string | object; dark: string | object }–Basemap style URL or style object per color scheme. Required.
engine'maplibre' | 'mapbox''maplibre'Which peer package draws the map.
accessTokenstring–Mapbox token. Required for engine 'mapbox'.
layersLayer[]–deck.gl layers, drawn interleaved through `MapLibreOverlay` (maplibre) or `MapboxOverlay` (mapbox). Replace the array to update.
fit[[west, south], [east, north]]–Bounds to fit. Changing it fits again.
paddingPartial<{ top: number; right: number; bottom: number; left: number }>stageInsets, else 0Pixels covered by UI on each side, used by fit. Each side defaults to the active PageFrame's `stageInsets`, so a map behind a PageFrame fits clear of the panel with no prop; a side you pass wins.
center[lng, lat]–Initial center.
zoomnumber–Initial zoom.
labelstring'Map' (localized)Accessible name of the map region.
mapOptionsRecord<string, unknown>–Extra constructor options for the engine's Map (maxZoom, projection, …).
onmapready(map) => void–Called once with the engine's map after the first style loads. Use it for engine-specific work (sources, events).
classstring–Extra classes on the stage.
childrenSnippet–Overlay content: MapControls, a legend.

Props

PropTypeDefaultDescription
locatebooleanfalseAdds a geolocate button (asks for the location permission on click).
classstring–Extra classes on the control column.

Functions

PropTypeDefaultDescription
tokenColor(token, alpha?)(token: string, alpha?: number) => [number, number, number, number]–A token (`--ca-brand-main`) or CSS color as [r, g, b, a] (a in 0-1). Transparent black for an unset token and on the server.
mapColors()() => MapColors–brand, brandMuted, surface, text, textMuted, outline, success, warning, error, info as RGBA, reactive to the theme.
useMapTheme()() => { scheme; colors; color(token, alpha?) }–The same, as a reactive object for component init.
rgbaCss(color)(color: Rgba) => string–An RGBA array as a CSS rgba() string.