Eight files under src/live/:
- constants.ts: throughput-discipline numbers (MAX_TRAIL_LENGTH=200,
reconnect backoff [1s/2s/4s/8s/16s, 30s ceiling], STALE_CONNECTION_MS).
- protocol.ts: zod discriminatedUnion('type', ...) for inbound
(subscribed / unsubscribed / position / error). PositionEntry +
SubscribeResult types per processor-ws-contract.
- connection-store.ts: Zustand store with status state machine.
- position-store.ts: Zustand store with latestByDevice + trailsByDevice
Maps, applySnapshot/applyPositions/clearForEvent/selectDevice
actions. Ring-buffer cap on trails; same-coordinate dedup.
- coalescer.ts: ~30-line rAF coalescer. Per-device buffer; flushes once
per animation frame regardless of receive rate.
- ws-client.ts: state machine (idle / connecting / connected /
reconnecting / closed) with exponential backoff, re-subscribe on
reconnect, stale-connection check, subscribe correlation via
pending-map + 5s timeout. URL resolution helper toAbsoluteWsUrl()
for same-origin path inputs.
- bootstrap.tsx: <LiveBootstrap> creates the client when authenticated,
wires positions through the coalescer to the position store, tears
down on logout. getLiveClient() exposes the singleton for 2.7.
- index.ts: barrel re-exports.
main.tsx wraps <App /> in <LiveBootstrap> alongside <AuthBootstrap>.
Deviations:
1. Skipped the setStatus helper inside createLiveClient; conditional
Parameters<> generics were hostile. Direct
useConnectionStore.getState().setStatus(...) at the ~6 call sites.
2. subscribe() adds to the subscriptions Set even when not connected
(so it replays on reconnect). Caller handles 'not-connected' by
waiting for connection-store status transition.
3. onPosition returns an unsubscribe fn (Set-based). Multi-handler is
free; lets future debug panels/tests attach.
Bundle: src/live/ adds ~15KB raw to the main bundle (mostly zod's
discriminated-union runtime). Total 393KB / 120KB gz.
11 KiB
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,currentColorfor tint surfaces. Plusbackground.svg(the icon's plate) anddirection.svg(the heading arrow). Source from a permissive open-icon set (Lucide doesn't cover most racing categories — borrow fromPhosphor/Mage Icons/ commission). For v1, simple geometric silhouettes are enough.src/map/core/sprite-preload.tsexporting:preloadSprites(): Promise<void>— runs once at app boot. Loads each SVG into anImage, draws it onto acanvasatdevicePixelRatioscale tinted with the colour, composites with the background, stores theImageDatain a module-level registry keyed${category}-${color}.getSpriteRegistry(): ReadonlyMap<string, SpriteEntry>— accessor for<MapView>'s init step.installSprites(map: maplibregl.Map): void— callsmap.addImage(key, imageData, { pixelRatio })for every entry. Called from<MapView>'smapReadylistener after each style swap (sprites are wiped along with sources).
src/map/core/categories.ts—mapCategoryToSprite(category: string): SpriteCategory— normalises Directusvehicles.kind(orentry_devices.rolelater) to one of the 7 sprite categories. Unknown →default.src/main.tsxupdated —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.tsxupdated —<MapView>'smapReadyflow callsinstallSprites(map)onceloaded()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
addImageon 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
pixelRatiooption onaddImagelets us pre-rasterise atdevicePixelRatioso retina screens render crisp.
Composition pattern
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
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
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
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 buildclean.preloadSprites()resolves within ~500 ms after app boot on a warm cache.- In the browser console after the page settles:
getSpriteRegistry().sizereturns 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
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 aREADME.mddocumenting the composite-tint pipeline and flagging the placeholder status (3.8 replaces with branded set).src/map/core/categories.ts—SpriteCategory/SpriteColor/SpriteKeytypes,SPRITE_CATEGORIES/SPRITE_COLORSarrays,mapCategoryToSprite()normaliser (handlescar / 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 canvasglobalCompositeOperation: '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 insidepreloadSprites()so the mapReady flow'swhenSpritesReady()call awaits the same promise.src/map/core/map-view.tsx—onStyleData()flow updated to awaitwhenSpritesReady()AND_map.loaded()before flippingmapReadytrue 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:
- 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. - Spec showed sample colours as
success: 'green'. Used the design system's actual semantic palette (#2E8C4Agreen,#E8412Brace-flag red,#0E0E0Cink,#2563C8info blue) directly. When 3.8 lands, these get rebound to TRM design tokens via CSS variables.
Smoke check (local pnpm dev):
- App boots;
getSpriteRegistry().sizereturns32after the page settles. /monitormap 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 61685e6.