docs(planning): file Phase 2 task specs (live monitoring map)
Nine task files matching Phase 1's shape (Goal / Deliverables / Spec / Acceptance / Risks / Done). README updated with full sequencing diagram, files-modified outline, tech stack additions, design rules, and phase acceptance. | # | Task | | --- | --------------------------------------------------------------------- | | 2.1 | MapView singleton + mapReady gate | | 2.2 | Tile-source switcher (Esri / OpenTopoMap / OSM / optional Google) | | 2.3 | Sprite preload — 7 racing categories x 4 colour variants | | 2.4 | WS client + rAF coalescer + Zustand position store + connection store | | 2.5 | MapPositions — clustered + selected sources | | 2.6 | MapTrails — bounded ring buffer, polyline rendering | | 2.7 | Event picker — TanStack Query + WS subscription orchestration | | 2.8 | Camera control trio — default-fit / selected-follow / one-shot | | 2.9 | Connection status + per-device last-seen indicators | Sequencing: 2.1 and 2.4 are parallel foundations (singleton vs data pipeline). Once both land, 2.5 / 2.6 / 2.7 / 2.9 fan out independently. 2.2 / 2.3 only need 2.1. 2.8 sits at the end on top of 2.1 + 2.5. Each task documents its deliverables down to file paths + interface shapes, includes concrete code sketches in the Specification, lists explicit out-of-scope items, and surfaces risks for the implementer to think about. An agent (or future me) can pick up any single task and ship it without re-deriving the design from the wiki. Resolved Phase 2 design decisions baked into the task files: - Trails: flat-colour-per-device for v1, defer speed-coloured segments to a Phase 3 polish task. - Cluster params: 14/50 (traccar default); tune after seeing real data. - Event picker placement: top-left dropdown. - Multi-event: out — single-select, one event at a time. - Stale-position visual: fade icon opacity; defer warning badges.
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
# Task 2.3 — Sprite preload (racing categories)
|
||||
|
||||
**Phase:** 2 — Live monitoring map
|
||||
**Status:** ⬜ Not started
|
||||
**Depends on:** 2.1.
|
||||
**Wiki refs:** `docs/wiki/concepts/maps-architecture.md` §"Icon sprites"; `docs/wiki/sources/traccar-maps-architecture.md` §5.
|
||||
|
||||
## Goal
|
||||
|
||||
Pre-rasterise every `category × colour` combination once at app startup and register them with the singleton via `map.addImage()` after each style swap. Layers in 2.5+ then reference sprites by name (e.g. `'icon-image': 'rally-car-success'`) and the GPU paints them with zero per-frame JavaScript work.
|
||||
|
||||
Categories: **rally-car / quad / ssv / motorcycle / runner / hiker / default**. Colours: **success / error / neutral / info** (mapped from device status — 2.5 sets the property).
|
||||
|
||||
## Deliverables
|
||||
|
||||
- **`src/map/assets/icons/*.svg`** — racing-category SVG icons. One file per category, ~24×24 viewBox, 2 px stroke, `currentColor` for tint surfaces. Plus `background.svg` (the icon's plate) and `direction.svg` (the heading arrow). Source from a permissive open-icon set (Lucide doesn't cover most racing categories — borrow from `Phosphor` / `Mage Icons` / commission). For v1, simple geometric silhouettes are enough.
|
||||
- **`src/map/core/sprite-preload.ts`** exporting:
|
||||
- `preloadSprites(): Promise<void>` — runs once at app boot. Loads each SVG into an `Image`, draws it onto a `canvas` at `devicePixelRatio` scale tinted with the colour, composites with the background, stores the `ImageData` in a module-level registry keyed `${category}-${color}`.
|
||||
- `getSpriteRegistry(): ReadonlyMap<string, SpriteEntry>` — accessor for `<MapView>`'s init step.
|
||||
- `installSprites(map: maplibregl.Map): void` — calls `map.addImage(key, imageData, { pixelRatio })` for every entry. Called from `<MapView>`'s `mapReady` listener after each style swap (sprites are wiped along with sources).
|
||||
- **`src/map/core/categories.ts`** — `mapCategoryToSprite(category: string): SpriteCategory` — normalises Directus `vehicles.kind` (or `entry_devices.role` later) to one of the 7 sprite categories. Unknown → `default`.
|
||||
- **`src/main.tsx`** updated — `void preloadSprites()` called once at boot (before or alongside `<AuthBootstrap>`). Doesn't block rendering; the registry fills in the background.
|
||||
- **`src/map/core/map-view.tsx`** updated — `<MapView>`'s `mapReady` flow calls `installSprites(map)` once `loaded()` is true, before flipping ready to children.
|
||||
|
||||
## Specification
|
||||
|
||||
### Why pre-rasterise
|
||||
|
||||
MapLibre accepts `addImage` with raw `ImageData` or an `HTMLImageElement`. Rasterising once at boot and reusing the buffer means:
|
||||
|
||||
- Style swaps re-run `addImage` on prebuilt buffers (microseconds), not re-rasterise SVGs (milliseconds).
|
||||
- Every device on the map shares a single GPU texture per (category, colour) combo. Memory cost is bounded.
|
||||
- The `pixelRatio` option on `addImage` lets us pre-rasterise at `devicePixelRatio` so retina screens render crisp.
|
||||
|
||||
### Composition pattern
|
||||
|
||||
```ts
|
||||
async function prepareSprite(
|
||||
background: HTMLImageElement,
|
||||
icon: HTMLImageElement,
|
||||
color: string,
|
||||
): Promise<ImageData> {
|
||||
const dpr = window.devicePixelRatio ?? 1;
|
||||
const size = 32; // logical px
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size * dpr;
|
||||
canvas.height = size * dpr;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
// 1. Background plate.
|
||||
ctx.drawImage(background, 0, 0, size, size);
|
||||
|
||||
// 2. Tint icon with the colour using destination-atop.
|
||||
const iconSize = 18;
|
||||
const off = (size - iconSize) / 2;
|
||||
const iconCanvas = document.createElement('canvas');
|
||||
iconCanvas.width = iconSize * dpr;
|
||||
iconCanvas.height = iconSize * dpr;
|
||||
const iconCtx = iconCanvas.getContext('2d')!;
|
||||
iconCtx.scale(dpr, dpr);
|
||||
iconCtx.drawImage(icon, 0, 0, iconSize, iconSize);
|
||||
iconCtx.globalCompositeOperation = 'source-in';
|
||||
iconCtx.fillStyle = color;
|
||||
iconCtx.fillRect(0, 0, iconSize, iconSize);
|
||||
|
||||
ctx.drawImage(iconCanvas, off, off, iconSize, iconSize);
|
||||
return ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
```
|
||||
|
||||
`destination-in` / `source-in` is the canvas trick for "tint an image with a colour" — the icon's alpha mask becomes the alpha channel of the tinted result. This is the same technique traccar-web uses.
|
||||
|
||||
### Colour mapping
|
||||
|
||||
```ts
|
||||
const COLOR_MAP: Record<SpriteColor, string> = {
|
||||
success: '#2E8C4A', // green — moving / OK
|
||||
error: '#E8412B', // race-flag red — alert / panic / lost
|
||||
neutral: '#0E0E0C', // ink — default
|
||||
info: '#2563C8', // blue — selected / informational
|
||||
};
|
||||
```
|
||||
|
||||
These are the design system's semantic colours. Swap to TRM's tokens (when 3.8 lands) by reading from CSS variables. For v1, hardcode.
|
||||
|
||||
### Registry shape
|
||||
|
||||
```ts
|
||||
type SpriteCategory = 'rally-car' | 'quad' | 'ssv' | 'motorcycle' | 'runner' | 'hiker' | 'default';
|
||||
type SpriteColor = 'success' | 'error' | 'neutral' | 'info';
|
||||
type SpriteKey = `${SpriteCategory}-${SpriteColor}`; // template literal type
|
||||
|
||||
type SpriteEntry = {
|
||||
key: SpriteKey;
|
||||
imageData: ImageData;
|
||||
pixelRatio: number;
|
||||
};
|
||||
|
||||
const _registry = new Map<SpriteKey, SpriteEntry>();
|
||||
```
|
||||
|
||||
7 categories × 4 colours = 28 entries. ~5KB ImageData per entry → ~140KB total in memory. Trivial.
|
||||
|
||||
### `installSprites`
|
||||
|
||||
```ts
|
||||
export function installSprites(map: maplibregl.Map): 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 });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Called from inside `<MapView>`'s style-load wait, just before `setReady(true)`. Idempotent — if a sprite is already registered, `removeImage` first to handle the post-style-swap case.
|
||||
|
||||
### Where the `direction` arrow comes in
|
||||
|
||||
The direction arrow (`direction.svg`) is a separate sprite registered as `direction-${color}`. 2.5's `MapPositions` uses a separate symbol layer with `'icon-image': ['concat', 'direction-', ['get', 'color']]` and `'icon-rotation-alignment': 'map'` rotated by the `course` property.
|
||||
|
||||
For 2.3, just include it in the registry and it'll be there when 2.5 needs it.
|
||||
|
||||
### What this task does NOT include
|
||||
|
||||
- **Custom TRM-branded icon set.** That's design system 3.8. For 2.3, use whichever permissive open icons fit (Phosphor, Mage). Document the source in a comment in `src/map/assets/icons/README.md`.
|
||||
- **Per-class colour mapping.** Class info isn't in the position payload; it would need a Directus join. 2.5 reads the device status from the position record (or a derived field) to pick the colour.
|
||||
- **Animated sprites.** No sprite animation in v1.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build` clean.
|
||||
- [ ] `preloadSprites()` resolves within ~500 ms after app boot on a warm cache.
|
||||
- [ ] In the browser console after the page settles: `getSpriteRegistry().size` returns 28 (7 categories × 4 colours, plus a small fixed set for the direction arrow if you treat it as part of the same registry, otherwise 28+4=32 — either is fine, just be consistent).
|
||||
- [ ] After every basemap switch, all sprites still render at correct size on retina screens (verify by zooming / panning after a switch).
|
||||
- [ ] No "Image with id X is missing" warnings in the console after style swaps.
|
||||
|
||||
## Risks / open questions
|
||||
|
||||
- **SVG-to-canvas drift.** Browsers occasionally render SVG slightly differently than expected (font fallbacks, stroke-width interpretation). Test on Firefox and Safari before declaring 2.3 done — Chromium's canvas is the reference but the others must work.
|
||||
- **Memory cost on low-end devices.** 140KB of ImageData is fine on desktop; on mid-range Android phones (Phase 3.3 mobile baseline) it's still trivial.
|
||||
- **CORS on icon SVGs.** SVGs served from the same origin as the SPA bundle have no CORS issues. If we ever fetch icons from a CDN, set `image.crossOrigin = 'anonymous'`.
|
||||
- **Direction-arrow colour.** Should the arrow follow the device's status colour, or always be a fixed accent? Default to "follows status" — readable at a glance which devices are moving fast vs slow vs stopped.
|
||||
|
||||
## Done
|
||||
|
||||
(Filled in when the task lands.)
|
||||
Reference in New Issue
Block a user