Files
spa/.planning/phase-2-live-map/03-sprite-preload.md
T
julian 87a738313e feat: task 2.1 MapView singleton + mapReady gate
- pnpm add maplibre-gl + -D @types/geojson.
- src/map/core/styles.ts: defaultStyle (OSM raster bootstrap; 2.2
  replaces with the basemap-switcher descriptor table).
- src/map/core/map-view.tsx: module-level Map singleton lazily created
  on first <MapView> mount, attached to a class="trm-map-host" detached
  <div> that React refs append/remove on mount/unmount. Style-data
  lifecycle flips mapReady false on every styledata event, polls
  loaded() at 33ms intervals, flips ready true once the style is
  loaded — the canonical MapLibre style-swap dance.
- Exports getMap()/getMapReady()/subscribeMapReady()/useMapReady (via
  useSyncExternalStore for SSR-safe + concurrent-safe reads). getMap()
  throws if called pre-mount; the explicit failure mode beats a
  null-able top-level export.
- src/routes/_authed/monitor.tsx: new /monitor route, full-viewport
  <MapView /> for 2.1 (no children — subsequent tasks plug in here).
- src/routes/_authed/index.tsx: home-page card now links to /monitor.
- eslint.config.js: override for src/map/** + src/live/** disables
  react-refresh/only-export-components. Same pattern as the existing
  overrides for shadcn primitives and route files.

Deviation: spec sketched a top-level `map` constant export; implemented
as `getMap(): MapLibreMap` (a function) so the singleton stays lazy
until <MapView> mounts. Top-level constant would either force eager
init (breaks SSR/tests) or be nullable (footgun). The function form
throws a clear error if called pre-mount.

Bundle: /monitor lazy chunk is 1MB raw / 274KB gzipped (MapLibre + CSS).
Other routes unaffected. Vite chunk-size warning is harmless.
2026-05-03 09:28:38 +02:00

7.9 KiB
Raw Blame History

Task 2.3 — Sprite preload (racing categories)

Phase: 2 — Live monitoring map Status: Not started Depends on: 2.1. Wiki refs: docs/wiki/concepts/maps-architecture.md §"Icon sprites"; docs/wiki/sources/traccar-maps-architecture.md §5.

Goal

Pre-rasterise every category × colour combination once at app startup and register them with the singleton via map.addImage() after each style swap. Layers in 2.5+ then reference sprites by name (e.g. 'icon-image': 'rally-car-success') and the GPU paints them with zero per-frame JavaScript work.

Categories: rally-car / quad / ssv / motorcycle / runner / hiker / default. Colours: success / error / neutral / info (mapped from device status — 2.5 sets the property).

Deliverables

  • src/map/assets/icons/*.svg — racing-category SVG icons. One file per category, ~24×24 viewBox, 2 px stroke, currentColor for tint surfaces. Plus background.svg (the icon's plate) and direction.svg (the heading arrow). Source from a permissive open-icon set (Lucide doesn't cover most racing categories — borrow from Phosphor / Mage Icons / commission). For v1, simple geometric silhouettes are enough.
  • src/map/core/sprite-preload.ts exporting:
    • preloadSprites(): Promise<void> — runs once at app boot. Loads each SVG into an Image, draws it onto a canvas at devicePixelRatio scale tinted with the colour, composites with the background, stores the ImageData in a module-level registry keyed ${category}-${color}.
    • getSpriteRegistry(): ReadonlyMap<string, SpriteEntry> — accessor for <MapView>'s init step.
    • installSprites(map: maplibregl.Map): void — calls map.addImage(key, imageData, { pixelRatio }) for every entry. Called from <MapView>'s mapReady listener after each style swap (sprites are wiped along with sources).
  • src/map/core/categories.tsmapCategoryToSprite(category: string): SpriteCategory — normalises Directus vehicles.kind (or entry_devices.role later) to one of the 7 sprite categories. Unknown → default.
  • src/main.tsx updated — void preloadSprites() called once at boot (before or alongside <AuthBootstrap>). Doesn't block rendering; the registry fills in the background.
  • src/map/core/map-view.tsx updated — <MapView>'s mapReady flow calls installSprites(map) once loaded() is true, before flipping ready to children.

Specification

Why pre-rasterise

MapLibre accepts addImage with raw ImageData or an HTMLImageElement. Rasterising once at boot and reusing the buffer means:

  • Style swaps re-run addImage on prebuilt buffers (microseconds), not re-rasterise SVGs (milliseconds).
  • Every device on the map shares a single GPU texture per (category, colour) combo. Memory cost is bounded.
  • The pixelRatio option on addImage lets us pre-rasterise at devicePixelRatio so retina screens render crisp.

Composition pattern

async function prepareSprite(
  background: HTMLImageElement,
  icon: HTMLImageElement,
  color: string,
): Promise<ImageData> {
  const dpr = window.devicePixelRatio ?? 1;
  const size = 32; // logical px
  const canvas = document.createElement('canvas');
  canvas.width = size * dpr;
  canvas.height = size * dpr;
  const ctx = canvas.getContext('2d')!;
  ctx.scale(dpr, dpr);

  // 1. Background plate.
  ctx.drawImage(background, 0, 0, size, size);

  // 2. Tint icon with the colour using destination-atop.
  const iconSize = 18;
  const off = (size - iconSize) / 2;
  const iconCanvas = document.createElement('canvas');
  iconCanvas.width = iconSize * dpr;
  iconCanvas.height = iconSize * dpr;
  const iconCtx = iconCanvas.getContext('2d')!;
  iconCtx.scale(dpr, dpr);
  iconCtx.drawImage(icon, 0, 0, iconSize, iconSize);
  iconCtx.globalCompositeOperation = 'source-in';
  iconCtx.fillStyle = color;
  iconCtx.fillRect(0, 0, iconSize, iconSize);

  ctx.drawImage(iconCanvas, off, off, iconSize, iconSize);
  return ctx.getImageData(0, 0, canvas.width, canvas.height);
}

destination-in / source-in is the canvas trick for "tint an image with a colour" — the icon's alpha mask becomes the alpha channel of the tinted result. This is the same technique traccar-web uses.

Colour mapping

const COLOR_MAP: Record<SpriteColor, string> = {
  success: '#2E8C4A', // green — moving / OK
  error: '#E8412B', // race-flag red — alert / panic / lost
  neutral: '#0E0E0C', // ink — default
  info: '#2563C8', // blue — selected / informational
};

These are the design system's semantic colours. Swap to TRM's tokens (when 3.8 lands) by reading from CSS variables. For v1, hardcode.

Registry shape

type SpriteCategory = 'rally-car' | 'quad' | 'ssv' | 'motorcycle' | 'runner' | 'hiker' | 'default';
type SpriteColor = 'success' | 'error' | 'neutral' | 'info';
type SpriteKey = `${SpriteCategory}-${SpriteColor}`; // template literal type

type SpriteEntry = {
  key: SpriteKey;
  imageData: ImageData;
  pixelRatio: number;
};

const _registry = new Map<SpriteKey, SpriteEntry>();

7 categories × 4 colours = 28 entries. ~5KB ImageData per entry → ~140KB total in memory. Trivial.

installSprites

export function installSprites(map: maplibregl.Map): void {
  for (const entry of _registry.values()) {
    if (map.hasImage(entry.key)) {
      map.removeImage(entry.key);
    }
    map.addImage(entry.key, entry.imageData, { pixelRatio: entry.pixelRatio });
  }
}

Called from inside <MapView>'s style-load wait, just before setReady(true). Idempotent — if a sprite is already registered, removeImage first to handle the post-style-swap case.

Where the direction arrow comes in

The direction arrow (direction.svg) is a separate sprite registered as direction-${color}. 2.5's MapPositions uses a separate symbol layer with 'icon-image': ['concat', 'direction-', ['get', 'color']] and 'icon-rotation-alignment': 'map' rotated by the course property.

For 2.3, just include it in the registry and it'll be there when 2.5 needs it.

What this task does NOT include

  • Custom TRM-branded icon set. That's design system 3.8. For 2.3, use whichever permissive open icons fit (Phosphor, Mage). Document the source in a comment in src/map/assets/icons/README.md.
  • Per-class colour mapping. Class info isn't in the position payload; it would need a Directus join. 2.5 reads the device status from the position record (or a derived field) to pick the colour.
  • Animated sprites. No sprite animation in v1.

Acceptance criteria

  • pnpm typecheck, pnpm lint, pnpm format:check, pnpm build clean.
  • preloadSprites() resolves within ~500 ms after app boot on a warm cache.
  • In the browser console after the page settles: getSpriteRegistry().size returns 28 (7 categories × 4 colours, plus a small fixed set for the direction arrow if you treat it as part of the same registry, otherwise 28+4=32 — either is fine, just be consistent).
  • After every basemap switch, all sprites still render at correct size on retina screens (verify by zooming / panning after a switch).
  • No "Image with id X is missing" warnings in the console after style swaps.

Risks / open questions

  • SVG-to-canvas drift. Browsers occasionally render SVG slightly differently than expected (font fallbacks, stroke-width interpretation). Test on Firefox and Safari before declaring 2.3 done — Chromium's canvas is the reference but the others must work.
  • Memory cost on low-end devices. 140KB of ImageData is fine on desktop; on mid-range Android phones (Phase 3.3 mobile baseline) it's still trivial.
  • CORS on icon SVGs. SVGs served from the same origin as the SPA bundle have no CORS issues. If we ever fetch icons from a CDN, set image.crossOrigin = 'anonymous'.
  • Direction-arrow colour. Should the arrow follow the device's status colour, or always be a fixed accent? Default to "follows status" — readable at a glance which devices are moving fast vs slow vs stopped.

Done

(Filled in when the task lands.)