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.
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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
|
||||
}
|
||||
@@ -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> ----------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||