Files
spa/.planning/phase-2-live-map/03-sprite-preload.md
T
julian 05543529e4 docs(planning): file Phase 2 task specs (live monitoring map)
Nine task files matching Phase 1's shape (Goal / Deliverables / Spec /
Acceptance / Risks / Done). README updated with full sequencing diagram,
files-modified outline, tech stack additions, design rules, and phase
acceptance.

| #   | Task                                                                  |
| --- | --------------------------------------------------------------------- |
| 2.1 | MapView singleton + mapReady gate                                     |
| 2.2 | Tile-source switcher (Esri / OpenTopoMap / OSM / optional Google)     |
| 2.3 | Sprite preload — 7 racing categories x 4 colour variants              |
| 2.4 | WS client + rAF coalescer + Zustand position store + connection store |
| 2.5 | MapPositions — clustered + selected sources                           |
| 2.6 | MapTrails — bounded ring buffer, polyline rendering                   |
| 2.7 | Event picker — TanStack Query + WS subscription orchestration         |
| 2.8 | Camera control trio — default-fit / selected-follow / one-shot        |
| 2.9 | Connection status + per-device last-seen indicators                   |

Sequencing: 2.1 and 2.4 are parallel foundations (singleton vs data
pipeline). Once both land, 2.5 / 2.6 / 2.7 / 2.9 fan out independently.
2.2 / 2.3 only need 2.1. 2.8 sits at the end on top of 2.1 + 2.5.

Each task documents its deliverables down to file paths + interface
shapes, includes concrete code sketches in the Specification, lists
explicit out-of-scope items, and surfaces risks for the implementer
to think about. An agent (or future me) can pick up any single task
and ship it without re-deriving the design from the wiki.

Resolved Phase 2 design decisions baked into the task files:
- Trails: flat-colour-per-device for v1, defer speed-coloured segments
  to a Phase 3 polish task.
- Cluster params: 14/50 (traccar default); tune after seeing real data.
- Event picker placement: top-left dropdown.
- Multi-event: out — single-select, one event at a time.
- Stale-position visual: fade icon opacity; defer warning badges.
2026-05-03 09:28:16 +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.)