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