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.
This commit is contained in:
2026-05-02 21:32:46 +02:00
parent 1c4a3b9d32
commit 0b54f87860
17 changed files with 358 additions and 12 deletions
+6
View File
@@ -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 <MapView>'s
// mapReady flow) awaits the same promise and so doesn't double-fetch.
void preloadSprites();
createRoot(document.getElementById('root')!).render(
<StrictMode>
+28
View File
@@ -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 `<MapView>`'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.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect x="3" y="3" width="26" height="26" rx="6" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="12" cy="12" r="6" fill="#000"/><circle cx="12" cy="12" r="2" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 153 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 2 L20 16 L14 14 L14 22 L10 22 L10 14 L4 16 Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 139 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="13" cy="5" r="2.5" fill="#000"/><rect x="7" y="9" width="5" height="6" rx="1" fill="#000"/><path d="M12 9 L15 11 L17 16 L15 17 L13 13 L12 18 L13 22 L11 22 L10 17 L8 22 L6 22 L8 16 L9 13 Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 281 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="6" cy="17" r="3" fill="#000"/><circle cx="18" cy="17" r="3" fill="#000"/><circle cx="6" cy="17" r="1.4" fill="#fff"/><circle cx="18" cy="17" r="1.4" fill="#fff"/><path d="M6 17 L11 11 H15 L18 17 Z" fill="#000"/><path d="M12 11 L14 6 L17 6" stroke="#000" stroke-width="2" stroke-linecap="round" fill="none"/></svg>

After

Width:  |  Height:  |  Size: 386 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M6 11 L8 8 H16 L18 11 V14 L17 16 H7 L6 14 Z" fill="#000"/><circle cx="6" cy="17" r="2" fill="#000"/><circle cx="18" cy="17" r="2" fill="#000"/><circle cx="6" cy="17" r="0.9" fill="#fff"/><circle cx="18" cy="17" r="0.9" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 308 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5 14 L6 9 L8 7 H16 L18 9 L19 14 V18 H17 V19 H15 V18 H9 V19 H7 V18 H5 V14 Z" fill="#000"/><circle cx="8" cy="17" r="1.6" fill="#fff"/><circle cx="16" cy="17" r="1.6" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 255 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="14" cy="5" r="2.5" fill="#000"/><path d="M13 9 L9 13 L8 17 L11 18 L13 14 L15 16 L13 21 L16 22 L19 14 L17 11 L15 9 Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 209 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5 13 L7 9 H17 L19 13 V16 L18 18 H6 L5 16 Z" fill="#000"/><rect x="9" y="10" width="6" height="3" rx="0.5" fill="#fff"/><circle cx="7" cy="18" r="1.6" fill="#000"/><circle cx="17" cy="18" r="1.6" fill="#000"/><circle cx="7" cy="18" r="0.7" fill="#fff"/><circle cx="17" cy="18" r="0.7" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 374 B

+80
View File
@@ -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
}
+14 -10
View File
@@ -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);
}
})();
}
// ---------- <MapView> ----------------------------------------------------
+198
View File
@@ -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<SpriteColor, string> = {
success: '#2E8C4A',
error: '#E8412B',
neutral: '#0E0E0C',
info: '#2563C8',
};
const CATEGORY_SVG: Record<SpriteCategory, string> = {
'rally-car': rallyCarSvg,
quad: quadSvg,
ssv: ssvSvg,
motorcycle: motorcycleSvg,
runner: runnerSvg,
hiker: hikerSvg,
default: defaultSvg,
};
// ---------- Registry & lifecycle ----------------------------------------
const _registry = new Map<SpriteKey, SpriteEntry>();
let _preloadPromise: Promise<void> | 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<void> {
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<void> {
return preloadSprites();
}
export function getSpriteRegistry(): ReadonlyMap<SpriteKey, SpriteEntry> {
return _registry;
}
/**
* Walk the registry and `addImage` every sprite to the map. Called from
* `<MapView>`'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<void> {
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<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (err) => reject(err);
img.src = src;
});
}