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

150 lines
7.9 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
(Filled in when the task lands.)