- 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.
11 KiB
Task 2.2 — Tile-source switcher
Phase: 2 — Live monitoring map
Status: ⬜ Not started
Depends on: 2.1.
Wiki refs: docs/wiki/concepts/maps-architecture.md §"Tile sources"; docs/wiki/sources/traccar-maps-architecture.md §3.
Goal
Let the operator switch the basemap between Esri World Imagery (satellite), OpenTopoMap (terrain), and OSM (street) — plus an optional Google Satellite source when an operator-supplied API key is present in the runtime config. Style swaps trigger the mapReady gate (2.1) so child components remount cleanly.
After this task, a small floating control on the map lets operators pick the basemap that fits the rally context (satellite for navigation, topo for mountain stages, OSM for sanity).
Deliverables
src/map/core/styles.ts(replacing the 2.1 stub) — exports aBASEMAP_STYLES: BasemapDescriptor[]array. Each descriptor:Inline-built raster styles for Esri / OpenTopoMap / OSM via atype BasemapDescriptor = { id: 'esri-satellite' | 'opentopo' | 'osm' | 'google-satellite'; label: string; style: maplibregl.StyleSpecification | string; // inline raster style or URL available: (cfg: RuntimeConfig) => boolean; // gates Google on googleMapsKey attribution: string; };styleCustom({ tiles, attribution })helper that mirrors traccar-web's pattern (single raster source + raster layer). Google variant builds agoogle://source when the key is present.src/map/core/basemap-switcher.tsx—<BasemapSwitcher />floating control:- Top-right of the map (a small ink-bordered card with three / four buttons).
- Selected basemap stored in Zustand (
useMapPrefStore) so the choice persists across route navigations within the session. - On click, calls
map.setStyle(descriptor.style)and lets themapReadygate handle the unmount/remount of children.
src/map/core/map-pref-store.ts— small Zustand store for map preferences:basemapId: BasemapDescriptor['id']setBasemap(id): void- Persists to
localStoragevia Zustand'spersistmiddleware (key:trm-map-prefs).
src/routes/_authed/monitor.tsxupdated — render<BasemapSwitcher />as a child of<MapView>.- Optional:
pnpm add maplibre-google-maps— only if Google Satellite is being shipped at the same time. The module registers agoogle://protocol handler. Loaded lazily so the~50KBadapter doesn't ship on instances that don't use Google tiles. Concretely: a dynamicimport()insideBASEMAP_STYLES's init code path, gated onruntimeConfig.googleMapsKeypresence.
Specification
styleCustom helper
function styleCustom(opts: {
tiles: string[];
attribution: string;
minZoom?: number;
maxZoom?: number;
}): maplibregl.StyleSpecification {
return {
version: 8,
sources: {
raster: {
type: 'raster',
tiles: opts.tiles,
tileSize: 256,
attribution: opts.attribution,
minzoom: opts.minZoom ?? 0,
maxzoom: opts.maxZoom ?? 19,
},
},
glyphs: 'https://fonts.openmaptiles.org/{fontstack}/{range}.pbf',
layers: [{ id: 'raster', type: 'raster', source: 'raster' }],
};
}
The glyphs URL lets symbol layers (sprites + labels added in 2.3+) render text on raster basemaps.
Initial set
export const BASEMAP_STYLES: BasemapDescriptor[] = [
{
id: 'esri-satellite',
label: 'Satellite',
style: styleCustom({
tiles: [
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
],
attribution: 'Tiles © Esri',
maxZoom: 19,
}),
available: () => true,
attribution: 'Esri World Imagery',
},
{
id: 'opentopo',
label: 'Topo',
style: styleCustom({
tiles: [
'https://a.tile.opentopomap.org/{z}/{x}/{y}.png',
'https://b.tile.opentopomap.org/{z}/{x}/{y}.png',
'https://c.tile.opentopomap.org/{z}/{x}/{y}.png',
],
attribution: '© OpenTopoMap (CC-BY-SA)',
maxZoom: 17,
}),
available: () => true,
attribution: 'OpenTopoMap',
},
{
id: 'osm',
label: 'Street',
style: styleCustom({
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
attribution: '© OpenStreetMap contributors',
maxZoom: 19,
}),
available: () => true,
attribution: 'OpenStreetMap',
},
{
id: 'google-satellite',
label: 'Google',
style: ((cfg) =>
styleCustom({
tiles: [`google://satellite/{z}/{x}/{y}?key=${cfg.googleMapsKey}`],
attribution: '© Google',
maxZoom: 22,
})) as unknown as maplibregl.StyleSpecification,
// ↑ build lazily inside the switcher; the descriptor above is illustrative.
available: (cfg) => Boolean(cfg.googleMapsKey),
attribution: 'Google Maps',
},
];
(Build the Google entry's style lazily at switch time using the runtime config; the descriptor table above shows the shape.)
Lazy maplibre-google-maps registration
// Inside the basemap switcher, on first use of google-satellite:
async function ensureGoogleProtocol() {
if ((maplibregl as any).__trmGoogleRegistered) return;
const { googleProtocol } = await import('maplibre-google-maps');
maplibregl.addProtocol('google', googleProtocol);
(maplibregl as any).__trmGoogleRegistered = true;
}
Skip the lazy-load entirely if runtimeConfig.googleMapsKey is empty — the descriptor's available() returns false and the option doesn't appear in the switcher.
Basemap-switcher UI
Small floating card, top-right of the map:
export function BasemapSwitcher() {
const cfg = useRuntimeConfig();
const current = useMapPrefStore((s) => s.basemapId);
const setBasemap = useMapPrefStore((s) => s.setBasemap);
const available = BASEMAP_STYLES.filter((s) => s.available(cfg));
return (
<div className="absolute top-3 right-3 bg-background border border-border rounded-md shadow-sm overflow-hidden">
{available.map((s) => (
<button
key={s.id}
onClick={() => switchTo(s)}
className={cn(
'block px-3 py-1.5 text-sm w-full text-left',
current === s.id ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50',
)}
>
{s.label}
</button>
))}
</div>
);
}
switchTo(descriptor) is the function that:
- (If google)
await ensureGoogleProtocol(). - Calls
map.setStyle(descriptor.style). - Updates
useMapPrefStoreso the choice persists.
The actual setStyle triggers styledata → mapReady flips false → flips true once loaded → children remount.
Persistence
Zustand persist middleware on useMapPrefStore. Key trm-map-prefs. Selected basemap survives reloads.
On first visit, default to esri-satellite — the most universally useful for rally context.
Acceptance criteria
pnpm typecheck,pnpm lint,pnpm format:check,pnpm buildclean./monitorshows the basemap switcher top-right with three buttons (Satellite / Topo / Street).- Clicking a button changes the basemap. The selected button is visually highlighted.
- Reloading the page restores the previously selected basemap.
- If
googleMapsKeyis set inconfig.json, a fourth "Google" button appears. Clicking it loads Google satellite tiles via the adapter. - If
googleMapsKeyis empty/missing, the Google button is hidden — andmaplibre-google-mapsis not imported (verify in DevTools network tab; the chunk should not appear). - After a basemap switch,
useMapReady()flips to false then back to true within ~500ms. Console shows no errors.
Risks / open questions
- OSM tile-server policy.
tile.openstreetmap.orgis rate-limited and asks for a User-Agent. For low-traffic dogfood it's fine; for production we'd self-host or switch to a paid OSM mirror (MapTiler / Stadia). Document but defer. - Esri ToS. Esri's "World Imagery" raster service is free for non-commercial use with attribution. Confirm before going public; for a closed dogfood it's clearly fine.
- OpenTopoMap rate limits. The official tile servers are best-effort. Same defer-until-it-bites posture.
- Google's official Map Tiles API requires session tokens.
maplibre-google-mapshandles this internally per the adapter's docs. Confirm the API key has the Map Tiles API enabled (not just JS Maps). - Style switcher placement. Top-right floats over the map; if Phase 3.4's per-device detail panel goes top-right too, we'll need to reposition. For Phase 2 v1, top-right is fine.
Done
src/map/core/styles.ts— replaces 2.1's stub with the fullBasemapDescriptormodel. Each entry hasid,label,available(cfg),buildStyle(cfg). Four entries: Esri World Imagery (satellite), OpenTopoMap (topo), OSM (street), Google Satellite (gated oncfg.googleMapsKey).styleCustom()helper synthesises a single-raster-source MapLibre style with aglyphsURL so future symbol layers render text.defaultStyleretained as the bootstrap.ensureGoogleProtocol()lazy-loadsmaplibre-google-mapson first use of the Google variant;_googleRegisteredguards re-registration.src/map/core/map-pref-store.ts— Zustand store withpersistmiddleware (name: 'trm-map-prefs',version: 1). HoldsbasemapId+setBasemap. Survives reloads via localStorage.src/map/core/basemap-switcher.tsx—<BasemapSwitcher />floating control top-right. Reads available basemaps (filtered byavailable(cfg)), highlights the active one, applies on click viagetMap().setStyle(...). Mount-time bootstrap effect applies the persisted choice once at first paint; user-drivenonClickkeeps aswitchingstate for visual feedback.src/routes/_authed/monitor.tsx— renders<BasemapSwitcher />as a child of<MapView>.src/vite-env.d.ts—declare module 'maplibre-google-maps'ambient declaration; the package ships JS only, no.d.ts.
Deps installed: maplibre-google-maps@1.1.0 (runtime; lazy-imported only when the Google basemap is selected).
Deviations from spec:
- Spec sketched
applyBasemapas a unified function used by both the mount-bootstrap effect and the user-drivenonClick. React 19's newreact-hooks/set-state-in-effectrule flagged thesetSwitchingcall inside the bootstrap effect as a cascading-render risk. Split into two paths: bootstrap callssetStyledirectly without UI state (no need — the map is already showing OSM, the swap is silent);onClickkeepssetSwitchingfor the per-click visual feedback. Cleaner anyway. - Spec sketched
BasemapDescriptor.styleasStyleSpecification | string. Implemented asbuildStyle: (cfg) => StyleSpecification— a function. Reason: the Google variant'sstyledepends oncfg.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):
/monitorshows 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.
- With
googleMapsKeyempty inpublic/config.json, the Google button is hidden andmaplibre-google-mapsdoesn't appear in the network tab.
Bundle: MapLibre is now its own chunk (maplibre-gl-*.js, 1MB / 273KB gzipped). Main bundle 371KB / 114KB gzipped.
Landed in 3a9e07b.