# 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` — 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` — accessor for ``'s init step. - `installSprites(map: maplibregl.Map): void` — calls `map.addImage(key, imageData, { pixelRatio })` for every entry. Called from ``'s `mapReady` listener after each style swap (sprites are wiped along with sources). - **`src/map/core/categories.ts`** — `mapCategoryToSprite(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 ``). Doesn't block rendering; the registry fills in the background. - **`src/map/core/map-view.tsx`** updated — ``'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 ```ts async function prepareSprite( background: HTMLImageElement, icon: HTMLImageElement, color: string, ): Promise { 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 ```ts const COLOR_MAP: Record = { 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 ```ts 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(); ``` 7 categories × 4 colours = 28 entries. ~5KB ImageData per entry → ~140KB total in memory. Trivial. ### `installSprites` ```ts 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 ``'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.ts`** — `SpriteCategory` / `SpriteColor` / `SpriteKey` types, `SPRITE_CATEGORIES` / `SPRITE_COLORS` arrays, `mapCategoryToSprite()` normaliser (handles `car / pickup / truck` → `rally-car`, `atv / four-wheeler` → `quad`, etc.), `inferColor()` helper used by 2.5. - **`src/map/core/sprite-preload.ts`** — `preloadSprites()` (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.tsx`** — `void 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.tsx`** — `onStyleData()` 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`.