Files
spa/.planning/phase-2-live-map/03-sprite-preload.md
T

11 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

  • src/map/assets/icons/ — 9 SVGs: background.svg (rounded square plate), direction.svg (arrow), and one per category (rally-car, quad, ssv, motorcycle, runner, hiker, default). Plus a README.md documenting the composite-tint pipeline and flagging the placeholder status (3.8 replaces with branded set).
  • src/map/core/categories.tsSpriteCategory / SpriteColor / SpriteKey types, SPRITE_CATEGORIES / SPRITE_COLORS arrays, mapCategoryToSprite() normaliser (handles car / pickup / truckrally-car, atv / four-wheelerquad, etc.), inferColor() helper used by 2.5.
  • src/map/core/sprite-preload.tspreloadSprites() (idempotent, memoised promise), whenSpritesReady() (alias), getSpriteRegistry() (read-only access), installSprites(map) (called from mapReady flow). composeCategorySprite() draws the background plate then composites a tinted icon centred on it; composeDirectionSprite() tints the arrow only (no plate). tintImage() uses canvas globalCompositeOperation: 'source-in' + fillRect — the SVG's alpha mask becomes the tint mask.
  • src/main.tsxvoid preloadSprites() fired once at boot. The promise is memoised inside preloadSprites() so the mapReady flow's whenSpritesReady() call awaits the same promise.
  • src/map/core/map-view.tsxonStyleData() flow updated to await whenSpritesReady() AND _map.loaded() before flipping mapReady true and installing sprites. Sprites get reinstalled after every style swap.

Registry size: 7 categories × 4 colours = 28 entries, plus 4 direction-only entries (one per colour, no plate). 32 total. Each at ~5KB ImageData = ~160KB in memory.

Deviations from spec:

  1. Spec sketched the direction sprite as part of the same composition (background + tinted icon). Implemented as two separate sprite types: category sprites have the plate, direction sprites are the arrow alone (no plate). Reason: the direction sprite is rendered as a separate symbol layer in 2.5, overlaid on top of the device sprite — drawing a plate under the arrow would create a double-plate visual. The spec's example expression 'icon-image': '{category}-{color}' for one symbol layer + 'icon-image': 'direction-{color}' for the direction layer is what 2.5 will actually consume.
  2. Spec showed sample colours as success: 'green'. Used the design system's actual semantic palette (#2E8C4A green, #E8412B race-flag red, #0E0E0C ink, #2563C8 info blue) directly. When 3.8 lands, these get rebound to TRM design tokens via CSS variables.

Smoke check (local pnpm dev):

  • App boots; getSpriteRegistry().size returns 32 after the page settles.
  • /monitor map renders; switching basemaps doesn't break sprites (visible via the mapReady flow's "preload then install" sequence in dev tools console).
  • No "Image with id X is missing" warnings.

Bundle: SVGs are inlined as data URLs by Vite (each is ~200-400 bytes). The sprite-preload module itself is small (~3KB). The map chunk gains ~5KB.

Landed in 61685e6.