feat: task 2.2 tile-source switcher
- pnpm add maplibre-google-maps (runtime; lazy-imported on Google use). - src/map/core/styles.ts: BasemapDescriptor model with four entries (Esri Satellite / OpenTopoMap / OSM / Google Satellite). Google entry gated on cfg.googleMapsKey via available(cfg). styleCustom() builds a single-raster-source MapLibre style with a glyphs URL for future label rendering. ensureGoogleProtocol() lazy-loads maplibre-google-maps + addProtocol on first use; _googleRegistered guards re-registration. - src/map/core/map-pref-store.ts: Zustand + persist middleware (key: trm-map-prefs, version: 1). Holds basemapId + setBasemap. - src/map/core/basemap-switcher.tsx: floating control top-right of <MapView>. Filters by available(cfg), highlights active, applies via getMap().setStyle(). Mount-time bootstrap (no UI state) + user-driven onClick (with switching state) split to satisfy React 19's react-hooks/set-state-in-effect rule. - src/routes/_authed/monitor.tsx: renders <BasemapSwitcher /> as a child of <MapView>. - src/vite-env.d.ts: ambient module declaration for maplibre-google-maps (no .d.ts ships in the package). Deviations: - BasemapDescriptor.style is buildStyle(cfg): StyleSpecification, not the spec's StyleSpecification|string union. Google needs the cfg key at build time; uniform function shape across all entries is cleaner. - Bootstrap apply path doesn't use the switching UI state (no visual feedback needed during the silent first-paint flip from OSM to user preference). User-click path keeps setSwitching for the in-flight indicator. Bundle: MapLibre is now its own chunk (1MB / 273KB gz), shared across maplibre consumers. Main bundle 371KB / 114KB gz.
This commit is contained in:
@@ -203,12 +203,14 @@ Three new files + small updates to existing surfaces:
|
||||
**Deviation flagged:**
|
||||
|
||||
The spec sketched the singleton's accessor as `map` (a top-level export). Implemented as `getMap(): MapLibreMap` instead — a function call rather than an exported constant. Two reasons:
|
||||
|
||||
1. The singleton is created lazily on first `<MapView>` mount, not at module import. A top-level `map` export would either force eager initialisation (breaks SSR / tests) or be `null` until something mounts (footgun).
|
||||
2. `getMap()` throws a clear error if called before `<MapView>` mounts — a missing pre-condition that's easy to surface and easy to read in stack traces. A `null`-able export forces every consumer to add their own null check.
|
||||
|
||||
Side-effect-only `Map*` components (2.5+) call `getMap()` from inside their `useEffect` *after* `useMapReady()` returns true, by which point the singleton exists and the style is loaded.
|
||||
Side-effect-only `Map*` components (2.5+) call `getMap()` from inside their `useEffect` _after_ `useMapReady()` returns true, by which point the singleton exists and the style is loaded.
|
||||
|
||||
**Smoke check (local `pnpm dev`):**
|
||||
|
||||
- `/monitor` renders a full-viewport OSM map.
|
||||
- Pan / zoom / scroll-zoom all work.
|
||||
- `/monitor` → `/` → `/monitor` keeps the map responsive (singleton survives navigation).
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.14.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"maplibre-google-maps": "^1.1.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
|
||||
Generated
+8
@@ -32,6 +32,9 @@ importers:
|
||||
maplibre-gl:
|
||||
specifier: ^5.24.0
|
||||
version: 5.24.0
|
||||
maplibre-google-maps:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0
|
||||
radix-ui:
|
||||
specifier: ^1.4.3
|
||||
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
@@ -1930,6 +1933,9 @@ packages:
|
||||
resolution: {integrity: sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==}
|
||||
engines: {node: '>=16.14.0', npm: '>=8.1.0'}
|
||||
|
||||
maplibre-google-maps@1.1.0:
|
||||
resolution: {integrity: sha512-bWeaLGvWriC8tMdgQfOdojnXGWyZ8Qva7mbs3qIqLHWAAuOEhlt2X0O4eGKuSKTMHGUIPEKlck3nquoWGhDQ7w==}
|
||||
|
||||
minimatch@10.2.5:
|
||||
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
@@ -4170,6 +4176,8 @@ snapshots:
|
||||
quickselect: 3.0.0
|
||||
tinyqueue: 3.0.0
|
||||
|
||||
maplibre-google-maps@1.1.0: {}
|
||||
|
||||
minimatch@10.2.5:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.5
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRuntimeConfig } from '@/config/context';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getMap } from './map-view';
|
||||
import { useMapPrefStore } from './map-pref-store';
|
||||
import { BASEMAP_STYLES, ensureGoogleProtocol, type BasemapDescriptor } from './styles';
|
||||
|
||||
/**
|
||||
* Floating basemap-picker control, top-right of the map.
|
||||
*
|
||||
* Reads the persisted choice from `useMapPrefStore` and applies it to the
|
||||
* map via `map.setStyle(...)`. Style swap fires the `mapReady` gate which
|
||||
* coordinates the unmount/remount of every other `Map*` component on the
|
||||
* map (sprites, sources, layers all get re-applied).
|
||||
*/
|
||||
export function BasemapSwitcher() {
|
||||
const cfg = useRuntimeConfig();
|
||||
const current = useMapPrefStore((s) => s.basemapId);
|
||||
const setBasemap = useMapPrefStore((s) => s.setBasemap);
|
||||
const [switching, setSwitching] = useState<string | null>(null);
|
||||
|
||||
const available = BASEMAP_STYLES.filter((d) => d.available(cfg));
|
||||
|
||||
// Bootstrap: apply the persisted basemap once at mount. The singleton
|
||||
// boots with OSM as a bootstrap; this flips to the user's saved choice.
|
||||
// No `switching` UI state needed here — there's nothing to indicate to
|
||||
// the user during the very first style swap.
|
||||
useEffect(() => {
|
||||
const descriptor = available.find((d) => d.id === current);
|
||||
if (!descriptor) return;
|
||||
void (async () => {
|
||||
if (descriptor.id === 'google-satellite') {
|
||||
await ensureGoogleProtocol();
|
||||
}
|
||||
getMap().setStyle(descriptor.buildStyle(cfg));
|
||||
})();
|
||||
// Run-once on mount. The user-driven onClick path handles subsequent
|
||||
// changes; depending on `available` / `cfg` here would re-run the
|
||||
// bootstrap unnecessarily.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
async function onClick(descriptor: BasemapDescriptor): Promise<void> {
|
||||
if (current === descriptor.id) return;
|
||||
setBasemap(descriptor.id);
|
||||
setSwitching(descriptor.id);
|
||||
try {
|
||||
if (descriptor.id === 'google-satellite') {
|
||||
await ensureGoogleProtocol();
|
||||
}
|
||||
getMap().setStyle(descriptor.buildStyle(cfg));
|
||||
} finally {
|
||||
setSwitching(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute top-3 right-3 bg-background border border-border rounded-md shadow-sm overflow-hidden z-10">
|
||||
{available.map((d) => {
|
||||
const isActive = current === d.id;
|
||||
const isPending = switching === d.id;
|
||||
return (
|
||||
<button
|
||||
key={d.id}
|
||||
type="button"
|
||||
onClick={() => void onClick(d)}
|
||||
disabled={isPending}
|
||||
className={cn(
|
||||
'block w-full text-left px-3 py-1.5 text-sm transition-colors',
|
||||
isActive ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50 text-foreground',
|
||||
isPending && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
{d.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { DEFAULT_BASEMAP_ID, type BasemapId } from './styles';
|
||||
|
||||
type MapPrefState = {
|
||||
basemapId: BasemapId;
|
||||
};
|
||||
|
||||
type MapPrefActions = {
|
||||
setBasemap: (id: BasemapId) => void;
|
||||
};
|
||||
|
||||
type Store = MapPrefState & MapPrefActions;
|
||||
|
||||
/**
|
||||
* Persists user preferences for the map: which basemap is selected,
|
||||
* eventually whether trails are visible, follow mode, etc. Survives
|
||||
* reloads via localStorage.
|
||||
*/
|
||||
export const useMapPrefStore = create<Store>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
basemapId: DEFAULT_BASEMAP_ID,
|
||||
setBasemap: (id) => set({ basemapId: id }),
|
||||
}),
|
||||
{
|
||||
name: 'trm-map-prefs',
|
||||
version: 1,
|
||||
},
|
||||
),
|
||||
);
|
||||
+121
-17
@@ -1,24 +1,128 @@
|
||||
import type { StyleSpecification } from 'maplibre-gl';
|
||||
import type { RuntimeConfig } from '@/config/schema';
|
||||
|
||||
export type BasemapId = 'esri-satellite' | 'opentopo' | 'osm' | 'google-satellite';
|
||||
|
||||
export type BasemapDescriptor = {
|
||||
id: BasemapId;
|
||||
label: string;
|
||||
available: (cfg: RuntimeConfig) => boolean;
|
||||
buildStyle: (cfg: RuntimeConfig) => StyleSpecification;
|
||||
};
|
||||
|
||||
/**
|
||||
* Bootstrap basemap style used until task 2.2 (basemap switcher) lands.
|
||||
* Build a minimal MapLibre style with a single raster source + raster layer.
|
||||
*
|
||||
* Plain OSM raster tiles. Free, attribution-required, polite User-Agent
|
||||
* recommended for any production load. For dogfood-scale traffic this is
|
||||
* fine.
|
||||
* The `glyphs` URL lets future symbol layers (sprites + labels) render text
|
||||
* even on raster basemaps.
|
||||
*/
|
||||
export const defaultStyle: StyleSpecification = {
|
||||
version: 8,
|
||||
sources: {
|
||||
raster: {
|
||||
type: 'raster',
|
||||
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
minzoom: 0,
|
||||
maxzoom: 19,
|
||||
function styleCustom(opts: {
|
||||
tiles: string[];
|
||||
attribution: string;
|
||||
minZoom?: number;
|
||||
maxZoom?: number;
|
||||
}): 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' }],
|
||||
};
|
||||
}
|
||||
|
||||
export const BASEMAP_STYLES: BasemapDescriptor[] = [
|
||||
{
|
||||
id: 'esri-satellite',
|
||||
label: 'Satellite',
|
||||
available: () => true,
|
||||
buildStyle: () =>
|
||||
styleCustom({
|
||||
tiles: [
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
],
|
||||
attribution: 'Tiles © Esri',
|
||||
maxZoom: 19,
|
||||
}),
|
||||
},
|
||||
glyphs: 'https://fonts.openmaptiles.org/{fontstack}/{range}.pbf',
|
||||
layers: [{ id: 'raster', type: 'raster', source: 'raster' }],
|
||||
};
|
||||
{
|
||||
id: 'opentopo',
|
||||
label: 'Topo',
|
||||
available: () => true,
|
||||
buildStyle: () =>
|
||||
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,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'osm',
|
||||
label: 'Street',
|
||||
available: () => true,
|
||||
buildStyle: () =>
|
||||
styleCustom({
|
||||
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
maxZoom: 19,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'google-satellite',
|
||||
label: 'Google',
|
||||
available: (cfg) => Boolean(cfg.googleMapsKey),
|
||||
buildStyle: (cfg) =>
|
||||
styleCustom({
|
||||
tiles: [`google://satellite/{z}/{x}/{y}?key=${cfg.googleMapsKey ?? ''}`],
|
||||
attribution: '© Google',
|
||||
maxZoom: 22,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_BASEMAP_ID: BasemapId = 'esri-satellite';
|
||||
|
||||
/**
|
||||
* Bootstrap style used by the singleton at first render — before runtime
|
||||
* config has finished loading and before the user has picked a basemap.
|
||||
*
|
||||
* OSM is the universal fallback (free, no key, no cookie). The basemap
|
||||
* switcher (`<BasemapSwitcher>`) flips to the user's chosen basemap once
|
||||
* the runtime config + persisted preference are available.
|
||||
*/
|
||||
export const defaultStyle: StyleSpecification = styleCustom({
|
||||
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
maxZoom: 19,
|
||||
});
|
||||
|
||||
/**
|
||||
* Lazily register the `google://` protocol handler on first use.
|
||||
*
|
||||
* The adapter is a runtime dep but its registration is gated on the
|
||||
* presence of a Google Maps key in the runtime config. If the key is
|
||||
* absent, this function never runs and the adapter's chunk doesn't load.
|
||||
*/
|
||||
let _googleRegistered = false;
|
||||
|
||||
export async function ensureGoogleProtocol(): Promise<void> {
|
||||
if (_googleRegistered) return;
|
||||
const [{ default: maplibregl }, { googleProtocol }] = await Promise.all([
|
||||
import('maplibre-gl'),
|
||||
import('maplibre-google-maps'),
|
||||
]);
|
||||
maplibregl.addProtocol('google', googleProtocol);
|
||||
_googleRegistered = true;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { BasemapSwitcher } from '@/map/core/basemap-switcher';
|
||||
import { MapView } from '@/map/core/map-view';
|
||||
|
||||
export const Route = createFileRoute('/_authed/monitor')({
|
||||
@@ -11,7 +12,17 @@ function MonitorPage() {
|
||||
// For now the parent _authed layout has no top-bar, so the map can
|
||||
// fill the full viewport — adjust when chrome lands.
|
||||
<div className="relative h-[calc(100vh-0rem)] w-full">
|
||||
<MapView />
|
||||
<MapView>
|
||||
{/*
|
||||
BasemapSwitcher renders inside <MapView> so it remounts after
|
||||
every style swap, ensuring the persisted preference is re-applied
|
||||
if the user does something funny (e.g. force-reloads style via
|
||||
devtools). It's also a sibling of the map canvas, not on top of
|
||||
it visually — the absolute-positioned card is positioned relative
|
||||
to <MapView>'s wrapper.
|
||||
*/}
|
||||
<BasemapSwitcher />
|
||||
</MapView>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Vendored
+10
@@ -14,3 +14,13 @@ interface ImportMetaEnv {
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
/**
|
||||
* `maplibre-google-maps` ships an `index.js` without type declarations.
|
||||
* The protocol handler we consume has a stable signature documented in
|
||||
* the package's README; declare the surface we use.
|
||||
*/
|
||||
declare module 'maplibre-google-maps' {
|
||||
import type { AddProtocolAction } from 'maplibre-gl';
|
||||
export const googleProtocol: AddProtocolAction;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user