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

170 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.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 `<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
```ts
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
```ts
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
```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<SpriteKey, SpriteEntry>();
```
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 `<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.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`.