From 0b54f87860f314d230432935599c61e8b2ad033c Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sat, 2 May 2026 21:32:46 +0200 Subject: [PATCH] feat: task 2.3 sprite preload (racing categories) - src/map/assets/icons/: 9 placeholder SVGs (background plate, direction arrow, 7 categories: rally-car / quad / ssv / motorcycle / runner / hiker / default). Hand-authored simple silhouettes; replaced in Phase 3.8 with the branded set. - src/map/core/categories.ts: SpriteCategory/SpriteColor types, mapCategoryToSprite() normaliser, inferColor() helper. - src/map/core/sprite-preload.ts: idempotent preloadSprites() with memoised promise, whenSpritesReady() alias, getSpriteRegistry() read-only access, installSprites(map) for the mapReady flow. Composition pipeline draws the plate + composites a tinted icon centred on it (category sprites) or tints the arrow alone (direction sprites). Tint via canvas globalCompositeOperation: 'source-in'. - src/main.tsx: void preloadSprites() fired at boot; the promise is memoised so mapReady flow awaits the same instance. - src/map/core/map-view.tsx: onStyleData() awaits whenSpritesReady() AND _map.loaded() before installing sprites and flipping mapReady true. Sprites reinstall on every style swap. Registry: 7 categories x 4 colours + 4 direction-only entries = 32 total. ~160KB in memory. Deviations: 1. Direction sprites have no plate (it's a separate symbol layer in 2.5 overlaid on the device sprite; double-plate would look wrong). 2. Hardcoded the design-system palette (#2E8C4A / #E8412B / #0E0E0C / #2563C8) directly. When 3.8 lands, these rebind to TRM tokens via CSS variables. --- .../02-tile-source-switcher.md | 1 + .../phase-2-live-map/03-sprite-preload.md | 22 +- .planning/phase-2-live-map/README.md | 2 +- src/main.tsx | 6 + src/map/assets/icons/README.md | 28 +++ src/map/assets/icons/background.svg | 1 + src/map/assets/icons/default.svg | 1 + src/map/assets/icons/direction.svg | 1 + src/map/assets/icons/hiker.svg | 1 + src/map/assets/icons/motorcycle.svg | 1 + src/map/assets/icons/quad.svg | 1 + src/map/assets/icons/rally-car.svg | 1 + src/map/assets/icons/runner.svg | 1 + src/map/assets/icons/ssv.svg | 1 + src/map/core/categories.ts | 80 +++++++ src/map/core/map-view.tsx | 24 ++- src/map/core/sprite-preload.ts | 198 ++++++++++++++++++ 17 files changed, 358 insertions(+), 12 deletions(-) create mode 100644 src/map/assets/icons/README.md create mode 100644 src/map/assets/icons/background.svg create mode 100644 src/map/assets/icons/default.svg create mode 100644 src/map/assets/icons/direction.svg create mode 100644 src/map/assets/icons/hiker.svg create mode 100644 src/map/assets/icons/motorcycle.svg create mode 100644 src/map/assets/icons/quad.svg create mode 100644 src/map/assets/icons/rally-car.svg create mode 100644 src/map/assets/icons/runner.svg create mode 100644 src/map/assets/icons/ssv.svg create mode 100644 src/map/core/categories.ts create mode 100644 src/map/core/sprite-preload.ts diff --git a/.planning/phase-2-live-map/02-tile-source-switcher.md b/.planning/phase-2-live-map/02-tile-source-switcher.md index f254b60..61aadb4 100644 --- a/.planning/phase-2-live-map/02-tile-source-switcher.md +++ b/.planning/phase-2-live-map/02-tile-source-switcher.md @@ -219,6 +219,7 @@ On first visit, default to `esri-satellite` — the most universally useful for 2. Spec sketched `BasemapDescriptor.style` as `StyleSpecification | string`. Implemented as `buildStyle: (cfg) => StyleSpecification` — a function. Reason: the Google variant's `style` depends on `cfg.googleMapsKey`, so it has to be computed at switch time. Uniform function shape across all entries keeps the type clean. **Smoke check (local `pnpm dev`):** + - `/monitor` shows three buttons top-right: Satellite / Topo / Street. Clicking each switches the basemap. - Selected button highlights via `bg-accent`. - Reloading the page restores the previously selected basemap. diff --git a/.planning/phase-2-live-map/03-sprite-preload.md b/.planning/phase-2-live-map/03-sprite-preload.md index af98016..ca75233 100644 --- a/.planning/phase-2-live-map/03-sprite-preload.md +++ b/.planning/phase-2-live-map/03-sprite-preload.md @@ -146,4 +146,24 @@ For 2.3, just include it in the registry and it'll be there when 2.5 needs it. ## Done -(Filled in when the task lands.) +- **`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 `PENDING_SHA`. diff --git a/.planning/phase-2-live-map/README.md b/.planning/phase-2-live-map/README.md index 79525ec..311cbda 100644 --- a/.planning/phase-2-live-map/README.md +++ b/.planning/phase-2-live-map/README.md @@ -50,7 +50,7 @@ When Phase 2 is done: | --- | ------------------------------------------------------------------------------------------ | ------ | | 2.1 | [MapView singleton + mapReady gate](./01-mapview-singleton.md) | 🟩 | | 2.2 | [Tile-source switcher](./02-tile-source-switcher.md) | 🟩 | -| 2.3 | [Sprite preload (racing categories)](./03-sprite-preload.md) | ⬜ | +| 2.3 | [Sprite preload (racing categories)](./03-sprite-preload.md) | 🟩 | | 2.4 | [WS client + rAF coalescer + Zustand position store](./04-ws-client-and-position-store.md) | ⬜ | | 2.5 | [MapPositions (clustered + selected sources)](./05-map-positions.md) | ⬜ | | 2.6 | [MapTrails (bounded ring buffer, polyline rendering)](./06-map-trails.md) | ⬜ | diff --git a/src/main.tsx b/src/main.tsx index f0b1865..4087171 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,6 +4,12 @@ import './styles/globals.css'; import App from './App.tsx'; import { RuntimeConfigProvider } from '@/config/provider'; import { AuthBootstrap } from '@/auth'; +import { preloadSprites } from '@/map/core/sprite-preload'; + +// Fire-and-forget preload of the map sprite registry. The promise is +// memoised inside preloadSprites; any later consumer (the 's +// mapReady flow) awaits the same promise and so doesn't double-fetch. +void preloadSprites(); createRoot(document.getElementById('root')!).render( diff --git a/src/map/assets/icons/README.md b/src/map/assets/icons/README.md new file mode 100644 index 0000000..8a2af9b --- /dev/null +++ b/src/map/assets/icons/README.md @@ -0,0 +1,28 @@ +# Map sprite icons (placeholder) + +Simple monochrome silhouettes used as the v1 racing-category sprite set. +**Replace in Phase 3.8** (TRM design system adoption) with the branded +icon set; until then these are functional placeholders. + +## Files + +- `background.svg` — square plate composited under every category icon. +- `direction.svg` — arrow rendered as a separate symbol layer rotated by `course`. +- `rally-car.svg` / `quad.svg` / `ssv.svg` / `motorcycle.svg` / `runner.svg` / `hiker.svg` / `default.svg` — one per racing category. Maps from Directus's vehicle / device kind via `src/map/core/categories.ts`. + +## Composition + +`src/map/core/sprite-preload.ts` rasterises each `(category, colour)` pair at boot: + +1. Draw `background.svg` to a 32 × 32 canvas at `devicePixelRatio` scale. +2. Draw the category SVG onto a smaller canvas, tint via `globalCompositeOperation: 'source-in'` + `fillRect` with the colour. +3. Composite the tinted icon centred on the background. +4. Cache the resulting `ImageData` in a module-level registry keyed `${category}-${colour}`. + +`installSprites(map)` walks the registry and calls `map.addImage(key, imageData, { pixelRatio })` for every entry. Called from ``'s `mapReady` flow on every style swap. + +## Style notes + +- All paths are filled (not stroked). The composite-tint trick uses the SVG's alpha channel as a mask; outlined-only icons would have transparent fills and disappear after tinting. +- ViewBox: `0 0 24 24` for category icons, `0 0 32 32` for `background.svg`. +- Colour: black `#000`. The colour applied at sprite-build time is what shows on screen. diff --git a/src/map/assets/icons/background.svg b/src/map/assets/icons/background.svg new file mode 100644 index 0000000..560b55f --- /dev/null +++ b/src/map/assets/icons/background.svg @@ -0,0 +1 @@ + diff --git a/src/map/assets/icons/default.svg b/src/map/assets/icons/default.svg new file mode 100644 index 0000000..87472d8 --- /dev/null +++ b/src/map/assets/icons/default.svg @@ -0,0 +1 @@ + diff --git a/src/map/assets/icons/direction.svg b/src/map/assets/icons/direction.svg new file mode 100644 index 0000000..e2af0ed --- /dev/null +++ b/src/map/assets/icons/direction.svg @@ -0,0 +1 @@ + diff --git a/src/map/assets/icons/hiker.svg b/src/map/assets/icons/hiker.svg new file mode 100644 index 0000000..57c8ec9 --- /dev/null +++ b/src/map/assets/icons/hiker.svg @@ -0,0 +1 @@ + diff --git a/src/map/assets/icons/motorcycle.svg b/src/map/assets/icons/motorcycle.svg new file mode 100644 index 0000000..563f279 --- /dev/null +++ b/src/map/assets/icons/motorcycle.svg @@ -0,0 +1 @@ + diff --git a/src/map/assets/icons/quad.svg b/src/map/assets/icons/quad.svg new file mode 100644 index 0000000..1fc552c --- /dev/null +++ b/src/map/assets/icons/quad.svg @@ -0,0 +1 @@ + diff --git a/src/map/assets/icons/rally-car.svg b/src/map/assets/icons/rally-car.svg new file mode 100644 index 0000000..047fe56 --- /dev/null +++ b/src/map/assets/icons/rally-car.svg @@ -0,0 +1 @@ + diff --git a/src/map/assets/icons/runner.svg b/src/map/assets/icons/runner.svg new file mode 100644 index 0000000..f33c19b --- /dev/null +++ b/src/map/assets/icons/runner.svg @@ -0,0 +1 @@ + diff --git a/src/map/assets/icons/ssv.svg b/src/map/assets/icons/ssv.svg new file mode 100644 index 0000000..e548d74 --- /dev/null +++ b/src/map/assets/icons/ssv.svg @@ -0,0 +1 @@ + diff --git a/src/map/core/categories.ts b/src/map/core/categories.ts new file mode 100644 index 0000000..cbf2784 --- /dev/null +++ b/src/map/core/categories.ts @@ -0,0 +1,80 @@ +export type SpriteCategory = + | 'rally-car' + | 'quad' + | 'ssv' + | 'motorcycle' + | 'runner' + | 'hiker' + | 'default'; + +export type SpriteColor = 'success' | 'error' | 'neutral' | 'info'; + +export type SpriteKey = `${SpriteCategory}-${SpriteColor}` | `direction-${SpriteColor}`; + +export const SPRITE_CATEGORIES: SpriteCategory[] = [ + 'rally-car', + 'quad', + 'ssv', + 'motorcycle', + 'runner', + 'hiker', + 'default', +]; + +export const SPRITE_COLORS: SpriteColor[] = ['success', 'error', 'neutral', 'info']; + +/** + * Map a free-form Directus `vehicles.kind` (or future `entry_devices.role`) + * value to one of the registered sprite categories. Unknown values fall + * back to `default`. + */ +export function mapCategoryToSprite(kind: string | null | undefined): SpriteCategory { + if (!kind) return 'default'; + const normalized = kind.toLowerCase().trim(); + switch (normalized) { + case 'rally-car': + case 'rally_car': + case 'car': + case 'auto': + case 'truck': + case 'pickup': + case 'offroad': + return 'rally-car'; + case 'quad': + case 'atv': + case 'four-wheeler': + return 'quad'; + case 'ssv': + case 'utv': + case 'side-by-side': + case 'sxs': + return 'ssv'; + case 'motorcycle': + case 'moto': + case 'bike': + case 'motorbike': + return 'motorcycle'; + case 'runner': + case 'running': + case 'run': + return 'runner'; + case 'hiker': + case 'hiking': + case 'hike': + case 'walker': + return 'hiker'; + default: + return 'default'; + } +} + +/** + * Map a position record / device status to a sprite colour. + * Phase 2 heuristic — Phase 3.4 (per-device detail) refines with real + * domain rules (panic, offline-detection, faulty-flag awareness). + */ +export function inferColor(opts: { speed?: number | null; isSelected?: boolean }): SpriteColor { + if (opts.isSelected) return 'info'; + if ((opts.speed ?? 0) > 1) return 'success'; // moving + return 'neutral'; // stopped / idle +} diff --git a/src/map/core/map-view.tsx b/src/map/core/map-view.tsx index 64fcc51..2f489b0 100644 --- a/src/map/core/map-view.tsx +++ b/src/map/core/map-view.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useSyncExternalStore, type ReactNode } from 'react'; import maplibregl, { type Map as MapLibreMap } from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import { defaultStyle } from './styles'; +import { installSprites, whenSpritesReady } from './sprite-preload'; // ---------- Singleton ---------------------------------------------------- @@ -75,20 +76,23 @@ export function useMapReady(): boolean { * * `styledata` fires for every style mutation (initial load, setStyle, * source updates). We treat it as "the style might have changed; re-check - * loaded() and gate the children". `setReady(false)` first so children - * unmount and clean up their custom sources/layers; once `loaded()` is - * true, flip back to ready and let them remount. + * loaded(), reinstall sprites, gate the children". `setReady(false)` + * first so children unmount and clean up their custom sources/layers; + * once `loaded()` AND the sprite preload have both resolved, install + * sprites and flip back to ready so children remount. */ function onStyleData(): void { setReady(false); - const check = (): void => { - if (_map?.loaded()) { - setReady(true); - } else { - setTimeout(check, 33); + void (async () => { + await whenSpritesReady(); + while (_map && !_map.loaded()) { + await new Promise((r) => setTimeout(r, 33)); } - }; - check(); + if (_map) { + installSprites(_map); + setReady(true); + } + })(); } // ---------- ---------------------------------------------------- diff --git a/src/map/core/sprite-preload.ts b/src/map/core/sprite-preload.ts new file mode 100644 index 0000000..03b37d2 --- /dev/null +++ b/src/map/core/sprite-preload.ts @@ -0,0 +1,198 @@ +import type { Map as MapLibreMap } from 'maplibre-gl'; +import { + SPRITE_CATEGORIES, + SPRITE_COLORS, + type SpriteCategory, + type SpriteColor, + type SpriteKey, +} from './categories'; + +import bgSvg from '@/map/assets/icons/background.svg'; +import directionSvg from '@/map/assets/icons/direction.svg'; +import rallyCarSvg from '@/map/assets/icons/rally-car.svg'; +import quadSvg from '@/map/assets/icons/quad.svg'; +import ssvSvg from '@/map/assets/icons/ssv.svg'; +import motorcycleSvg from '@/map/assets/icons/motorcycle.svg'; +import runnerSvg from '@/map/assets/icons/runner.svg'; +import hikerSvg from '@/map/assets/icons/hiker.svg'; +import defaultSvg from '@/map/assets/icons/default.svg'; + +type SpriteEntry = { + key: SpriteKey; + imageData: ImageData; + pixelRatio: number; +}; + +const ICON_SIZE = 32; // logical px (rendered at devicePixelRatio internally) +const ICON_INNER_SIZE = 18; + +/** + * Semantic colour map. Wired to TRM design tokens in Phase 3.8; for now + * hardcoded to the palette declared in `colors_and_type.css` (race-flag + * red, success green, info blue, neutral ink). + */ +const COLOR_HEX: Record = { + success: '#2E8C4A', + error: '#E8412B', + neutral: '#0E0E0C', + info: '#2563C8', +}; + +const CATEGORY_SVG: Record = { + 'rally-car': rallyCarSvg, + quad: quadSvg, + ssv: ssvSvg, + motorcycle: motorcycleSvg, + runner: runnerSvg, + hiker: hikerSvg, + default: defaultSvg, +}; + +// ---------- Registry & lifecycle ---------------------------------------- + +const _registry = new Map(); +let _preloadPromise: Promise | null = null; + +/** + * Load every (category, colour) sprite into the module-level registry. + * + * Idempotent: subsequent calls return the same in-flight or settled + * promise. The result lives in memory for the app's lifetime; sprites + * are reapplied to the map via `installSprites()` after every style swap. + */ +export function preloadSprites(): Promise { + if (_preloadPromise) return _preloadPromise; + _preloadPromise = doPreload().catch((err) => { + // Reset on failure so a retry can attempt again. + _preloadPromise = null; + throw err; + }); + return _preloadPromise; +} + +export function whenSpritesReady(): Promise { + return preloadSprites(); +} + +export function getSpriteRegistry(): ReadonlyMap { + return _registry; +} + +/** + * Walk the registry and `addImage` every sprite to the map. Called from + * ``'s mapReady flow on every style swap (style swaps wipe all + * registered images). + */ +export function installSprites(map: MapLibreMap): 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 }); + } +} + +// ---------- Composition pipeline ---------------------------------------- + +async function doPreload(): Promise { + const dpr = window.devicePixelRatio || 1; + const [bg, direction, ...categories] = await Promise.all([ + loadImage(bgSvg), + loadImage(directionSvg), + ...SPRITE_CATEGORIES.map((c) => loadImage(CATEGORY_SVG[c])), + ]); + + // Register category sprites: background + tinted category icon. + for (let i = 0; i < SPRITE_CATEGORIES.length; i++) { + const category = SPRITE_CATEGORIES[i]!; + const icon = categories[i]!; + for (const color of SPRITE_COLORS) { + const key: SpriteKey = `${category}-${color}`; + const data = composeCategorySprite(bg, icon, COLOR_HEX[color], dpr); + _registry.set(key, { key, imageData: data, pixelRatio: dpr }); + } + } + + // Register direction sprites: tinted arrow only (no background plate — + // the arrow renders as a separate symbol layer overlaid on the device + // sprite, not as a standalone marker). + for (const color of SPRITE_COLORS) { + const key: SpriteKey = `direction-${color}`; + const data = composeDirectionSprite(direction, COLOR_HEX[color], dpr); + _registry.set(key, { key, imageData: data, pixelRatio: dpr }); + } +} + +function composeCategorySprite( + bg: HTMLImageElement, + icon: HTMLImageElement, + color: string, + dpr: number, +): ImageData { + const canvas = document.createElement('canvas'); + canvas.width = ICON_SIZE * dpr; + canvas.height = ICON_SIZE * dpr; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('canvas 2d context unavailable'); + ctx.scale(dpr, dpr); + + // 1. Background plate (drawn ink-on-paper; the plate itself isn't tinted). + ctx.drawImage(bg, 0, 0, ICON_SIZE, ICON_SIZE); + + // 2. Tinted icon, centred on the plate. + const tinted = tintImage(icon, color, ICON_INNER_SIZE, dpr); + const offset = (ICON_SIZE - ICON_INNER_SIZE) / 2; + ctx.drawImage(tinted, offset, offset, ICON_INNER_SIZE, ICON_INNER_SIZE); + + return ctx.getImageData(0, 0, canvas.width, canvas.height); +} + +function composeDirectionSprite(arrow: HTMLImageElement, color: string, dpr: number): ImageData { + const size = ICON_SIZE; + const canvas = document.createElement('canvas'); + canvas.width = size * dpr; + canvas.height = size * dpr; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('canvas 2d context unavailable'); + ctx.scale(dpr, dpr); + + const tinted = tintImage(arrow, color, size, dpr); + ctx.drawImage(tinted, 0, 0, size, size); + return ctx.getImageData(0, 0, canvas.width, canvas.height); +} + +/** + * Tint an image with `color` using the canvas composite-source-in trick. + * + * 1. Draw the image (alpha mask intact). + * 2. Switch to `source-in` composite mode and fill the canvas with the + * target colour — only pixels inside the image's alpha get filled. + * 3. Result: a same-shape image in the target colour. + */ +function tintImage( + image: HTMLImageElement, + color: string, + size: number, + dpr: number, +): HTMLCanvasElement { + const canvas = document.createElement('canvas'); + canvas.width = size * dpr; + canvas.height = size * dpr; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('canvas 2d context unavailable'); + ctx.scale(dpr, dpr); + ctx.drawImage(image, 0, 0, size, size); + ctx.globalCompositeOperation = 'source-in'; + ctx.fillStyle = color; + ctx.fillRect(0, 0, size, size); + return canvas; +} + +function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = (err) => reject(err); + img.src = src; + }); +}