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
@@ -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. 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`):** **Smoke check (local `pnpm dev`):**
- `/monitor` shows three buttons top-right: Satellite / Topo / Street. Clicking each switches the basemap. - `/monitor` shows three buttons top-right: Satellite / Topo / Street. Clicking each switches the basemap.
- Selected button highlights via `bg-accent`. - Selected button highlights via `bg-accent`.
- Reloading the page restores the previously selected basemap. - 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 ## 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`.
+1 -1
View File
@@ -50,7 +50,7 @@ When Phase 2 is done:
| --- | ------------------------------------------------------------------------------------------ | ------ | | --- | ------------------------------------------------------------------------------------------ | ------ |
| 2.1 | [MapView singleton + mapReady gate](./01-mapview-singleton.md) | 🟩 | | 2.1 | [MapView singleton + mapReady gate](./01-mapview-singleton.md) | 🟩 |
| 2.2 | [Tile-source switcher](./02-tile-source-switcher.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.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.5 | [MapPositions (clustered + selected sources)](./05-map-positions.md) | ⬜ |
| 2.6 | [MapTrails (bounded ring buffer, polyline rendering)](./06-map-trails.md) | ⬜ | | 2.6 | [MapTrails (bounded ring buffer, polyline rendering)](./06-map-trails.md) | ⬜ |
+6
View File
@@ -4,6 +4,12 @@ import './styles/globals.css';
import App from './App.tsx'; import App from './App.tsx';
import { RuntimeConfigProvider } from '@/config/provider'; import { RuntimeConfigProvider } from '@/config/provider';
import { AuthBootstrap } from '@/auth'; 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( createRoot(document.getElementById('root')!).render(
<StrictMode> <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 maplibregl, { type Map as MapLibreMap } from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css';
import { defaultStyle } from './styles'; import { defaultStyle } from './styles';
import { installSprites, whenSpritesReady } from './sprite-preload';
// ---------- Singleton ---------------------------------------------------- // ---------- Singleton ----------------------------------------------------
@@ -75,20 +76,23 @@ export function useMapReady(): boolean {
* *
* `styledata` fires for every style mutation (initial load, setStyle, * `styledata` fires for every style mutation (initial load, setStyle,
* source updates). We treat it as "the style might have changed; re-check * source updates). We treat it as "the style might have changed; re-check
* loaded() and gate the children". `setReady(false)` first so children * loaded(), reinstall sprites, gate the children". `setReady(false)`
* unmount and clean up their custom sources/layers; once `loaded()` is * first so children unmount and clean up their custom sources/layers;
* true, flip back to ready and let them remount. * once `loaded()` AND the sprite preload have both resolved, install
* sprites and flip back to ready so children remount.
*/ */
function onStyleData(): void { function onStyleData(): void {
setReady(false); setReady(false);
const check = (): void => { void (async () => {
if (_map?.loaded()) { await whenSpritesReady();
setReady(true); while (_map && !_map.loaded()) {
} else { await new Promise((r) => setTimeout(r, 33));
setTimeout(check, 33);
} }
}; if (_map) {
check(); installSprites(_map);
setReady(true);
}
})();
} }
// ---------- <MapView> ---------------------------------------------------- // ---------- <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;
});
}