diff --git a/frontend/public/map-assets/sprites/light.json b/frontend/public/map-assets/sprites/light.json new file mode 100644 index 0000000..a8514fd --- /dev/null +++ b/frontend/public/map-assets/sprites/light.json @@ -0,0 +1 @@ +{"NL:S-road-1char":{"height":21,"pixelRatio":1,"width":21,"x":0,"y":36},"NL:S-road-2char":{"height":21,"pixelRatio":1,"width":22,"x":38,"y":21},"NL:S-road-3char":{"height":21,"pixelRatio":1,"width":25,"x":202,"y":18},"NL:S-road-4char":{"height":21,"pixelRatio":1,"width":29,"x":173,"y":0},"NL:S-road-5char":{"height":21,"pixelRatio":1,"width":32,"x":79,"y":0},"US:I-1char":{"height":21,"pixelRatio":1,"width":20,"x":84,"y":21},"US:I-2char":{"height":21,"pixelRatio":1,"width":23,"x":227,"y":18},"US:I-3char":{"height":21,"pixelRatio":1,"width":30,"x":143,"y":0},"US:I-4char":{"height":21,"pixelRatio":1,"width":32,"x":111,"y":0},"US:I-5char":{"height":21,"pixelRatio":1,"width":34,"x":45,"y":0},"aerodrome":{"height":19,"pixelRatio":1,"width":19,"x":60,"y":39},"animal":{"height":19,"pixelRatio":1,"width":19,"x":21,"y":42},"arrow":{"height":19,"pixelRatio":1,"width":19,"x":0,"y":57},"artwork":{"height":19,"pixelRatio":1,"width":19,"x":40,"y":42},"attraction":{"height":19,"pixelRatio":1,"width":19,"x":104,"y":21},"bar":{"height":19,"pixelRatio":1,"width":19,"x":123,"y":21},"beach":{"height":19,"pixelRatio":1,"width":19,"x":142,"y":21},"beauty":{"height":19,"pixelRatio":1,"width":19,"x":161,"y":21},"bench":{"height":19,"pixelRatio":1,"width":19,"x":180,"y":21},"books":{"height":19,"pixelRatio":1,"width":19,"x":199,"y":39},"building":{"height":17,"pixelRatio":1,"width":17,"x":193,"y":77},"bus_stop":{"height":19,"pixelRatio":1,"width":19,"x":218,"y":39},"cafe":{"height":19,"pixelRatio":1,"width":19,"x":237,"y":39},"capital":{"height":10,"pixelRatio":1,"width":10,"x":210,"y":77},"clothes":{"height":19,"pixelRatio":1,"width":19,"x":104,"y":40},"convenience":{"height":19,"pixelRatio":1,"width":19,"x":79,"y":42},"drinking_water":{"height":19,"pixelRatio":1,"width":19,"x":59,"y":58},"electronics":{"height":19,"pixelRatio":1,"width":19,"x":19,"y":61},"fast_food":{"height":19,"pixelRatio":1,"width":19,"x":0,"y":76},"ferry_terminal":{"height":19,"pixelRatio":1,"width":19,"x":38,"y":61},"forest":{"height":19,"pixelRatio":1,"width":19,"x":123,"y":40},"garden":{"height":19,"pixelRatio":1,"width":19,"x":142,"y":40},"generic_shield-1char":{"height":18,"pixelRatio":1,"width":19,"x":235,"y":0},"generic_shield-2char":{"height":18,"pixelRatio":1,"width":24,"x":60,"y":21},"generic_shield-3char":{"height":18,"pixelRatio":1,"width":33,"x":202,"y":0},"generic_shield-4char":{"height":18,"pixelRatio":1,"width":38,"x":0,"y":18},"generic_shield-5char":{"height":18,"pixelRatio":1,"width":45,"x":0,"y":0},"library":{"height":19,"pixelRatio":1,"width":19,"x":161,"y":40},"marina":{"height":19,"pixelRatio":1,"width":19,"x":180,"y":40},"museum":{"height":19,"pixelRatio":1,"width":19,"x":199,"y":58},"park":{"height":19,"pixelRatio":1,"width":19,"x":218,"y":58},"peak":{"height":19,"pixelRatio":1,"width":19,"x":237,"y":58},"post_office":{"height":19,"pixelRatio":1,"width":19,"x":98,"y":59},"restaurant":{"height":19,"pixelRatio":1,"width":19,"x":78,"y":61},"school":{"height":19,"pixelRatio":1,"width":19,"x":57,"y":77},"stadium":{"height":19,"pixelRatio":1,"width":19,"x":19,"y":80},"supermarket":{"height":19,"pixelRatio":1,"width":19,"x":0,"y":95},"theatre":{"height":19,"pixelRatio":1,"width":19,"x":38,"y":80},"toilets":{"height":19,"pixelRatio":1,"width":19,"x":117,"y":59},"townspot":{"height":10,"pixelRatio":1,"width":10,"x":220,"y":77},"train_station":{"height":19,"pixelRatio":1,"width":19,"x":136,"y":59},"university":{"height":19,"pixelRatio":1,"width":19,"x":155,"y":59},"zoo":{"height":19,"pixelRatio":1,"width":19,"x":174,"y":59}} \ No newline at end of file diff --git a/frontend/public/map-assets/sprites/light.png b/frontend/public/map-assets/sprites/light.png new file mode 100644 index 0000000..b2b90d9 Binary files /dev/null and b/frontend/public/map-assets/sprites/light.png differ diff --git a/frontend/public/map-assets/sprites/light@2x.json b/frontend/public/map-assets/sprites/light@2x.json new file mode 100644 index 0000000..4e98508 --- /dev/null +++ b/frontend/public/map-assets/sprites/light@2x.json @@ -0,0 +1 @@ +{"NL:S-road-1char":{"height":42,"pixelRatio":2,"width":42,"x":0,"y":72},"NL:S-road-2char":{"height":42,"pixelRatio":2,"width":44,"x":76,"y":42},"NL:S-road-3char":{"height":42,"pixelRatio":2,"width":50,"x":404,"y":36},"NL:S-road-4char":{"height":42,"pixelRatio":2,"width":58,"x":346,"y":0},"NL:S-road-5char":{"height":42,"pixelRatio":2,"width":64,"x":158,"y":0},"US:I-1char":{"height":42,"pixelRatio":2,"width":40,"x":168,"y":42},"US:I-2char":{"height":42,"pixelRatio":2,"width":46,"x":454,"y":36},"US:I-3char":{"height":42,"pixelRatio":2,"width":60,"x":286,"y":0},"US:I-4char":{"height":42,"pixelRatio":2,"width":64,"x":222,"y":0},"US:I-5char":{"height":42,"pixelRatio":2,"width":68,"x":90,"y":0},"aerodrome":{"height":38,"pixelRatio":2,"width":38,"x":120,"y":78},"animal":{"height":38,"pixelRatio":2,"width":38,"x":42,"y":84},"arrow":{"height":38,"pixelRatio":2,"width":38,"x":0,"y":114},"artwork":{"height":38,"pixelRatio":2,"width":38,"x":80,"y":84},"attraction":{"height":38,"pixelRatio":2,"width":38,"x":208,"y":42},"bar":{"height":38,"pixelRatio":2,"width":38,"x":246,"y":42},"beach":{"height":38,"pixelRatio":2,"width":38,"x":284,"y":42},"beauty":{"height":38,"pixelRatio":2,"width":38,"x":322,"y":42},"bench":{"height":38,"pixelRatio":2,"width":38,"x":360,"y":42},"books":{"height":38,"pixelRatio":2,"width":38,"x":398,"y":78},"building":{"height":34,"pixelRatio":2,"width":34,"x":386,"y":154},"bus_stop":{"height":38,"pixelRatio":2,"width":38,"x":436,"y":78},"cafe":{"height":38,"pixelRatio":2,"width":38,"x":474,"y":78},"capital":{"height":20,"pixelRatio":2,"width":20,"x":420,"y":154},"clothes":{"height":38,"pixelRatio":2,"width":38,"x":208,"y":80},"convenience":{"height":38,"pixelRatio":2,"width":38,"x":158,"y":84},"drinking_water":{"height":38,"pixelRatio":2,"width":38,"x":118,"y":116},"electronics":{"height":38,"pixelRatio":2,"width":38,"x":38,"y":122},"fast_food":{"height":38,"pixelRatio":2,"width":38,"x":0,"y":152},"ferry_terminal":{"height":38,"pixelRatio":2,"width":38,"x":76,"y":122},"forest":{"height":38,"pixelRatio":2,"width":38,"x":246,"y":80},"garden":{"height":38,"pixelRatio":2,"width":38,"x":284,"y":80},"generic_shield-1char":{"height":36,"pixelRatio":2,"width":38,"x":470,"y":0},"generic_shield-2char":{"height":36,"pixelRatio":2,"width":48,"x":120,"y":42},"generic_shield-3char":{"height":36,"pixelRatio":2,"width":66,"x":404,"y":0},"generic_shield-4char":{"height":36,"pixelRatio":2,"width":76,"x":0,"y":36},"generic_shield-5char":{"height":36,"pixelRatio":2,"width":90,"x":0,"y":0},"library":{"height":38,"pixelRatio":2,"width":38,"x":322,"y":80},"marina":{"height":38,"pixelRatio":2,"width":38,"x":360,"y":80},"museum":{"height":38,"pixelRatio":2,"width":38,"x":398,"y":116},"park":{"height":38,"pixelRatio":2,"width":38,"x":436,"y":116},"peak":{"height":38,"pixelRatio":2,"width":38,"x":474,"y":116},"post_office":{"height":38,"pixelRatio":2,"width":38,"x":196,"y":118},"restaurant":{"height":38,"pixelRatio":2,"width":38,"x":156,"y":122},"school":{"height":38,"pixelRatio":2,"width":38,"x":114,"y":154},"stadium":{"height":38,"pixelRatio":2,"width":38,"x":38,"y":160},"supermarket":{"height":38,"pixelRatio":2,"width":38,"x":0,"y":190},"theatre":{"height":38,"pixelRatio":2,"width":38,"x":76,"y":160},"toilets":{"height":38,"pixelRatio":2,"width":38,"x":234,"y":118},"townspot":{"height":20,"pixelRatio":2,"width":20,"x":440,"y":154},"train_station":{"height":38,"pixelRatio":2,"width":38,"x":272,"y":118},"university":{"height":38,"pixelRatio":2,"width":38,"x":310,"y":118},"zoo":{"height":38,"pixelRatio":2,"width":38,"x":348,"y":118}} \ No newline at end of file diff --git a/frontend/public/map-assets/sprites/light@2x.png b/frontend/public/map-assets/sprites/light@2x.png new file mode 100644 index 0000000..a6765ac Binary files /dev/null and b/frontend/public/map-assets/sprites/light@2x.png differ diff --git a/frontend/src/components/map/FleetMap.tsx b/frontend/src/components/map/FleetMap.tsx index adecf6d..43535a6 100644 --- a/frontend/src/components/map/FleetMap.tsx +++ b/frontend/src/components/map/FleetMap.tsx @@ -4,6 +4,7 @@ import { Protocol } from 'pmtiles' import { layers, namedFlavor } from '@protomaps/basemaps' import type { FleetDevice } from '@/lib/api' import { formatUptime } from '@/lib/utils' +import { useUIStore } from '@/lib/store' import 'maplibre-gl/dist/maplibre-gl.css' @@ -25,11 +26,11 @@ const STATUS_COLORS: Record = { unknown: '#eab308', } -function buildMapStyle() { +function buildMapStyle(theme: 'dark' | 'light') { return { version: 8 as const, glyphs: '/map-fonts/{fontstack}/{range}.pbf', - sprite: '/map-assets/sprites/dark', + sprite: `/map-assets/sprites/${theme}`, sources: { protomaps: { type: 'vector' as const, @@ -37,7 +38,7 @@ function buildMapStyle() { attribution: '© OpenStreetMap', }, }, - layers: layers('protomaps', namedFlavor('dark'), { lang: 'en' }), + layers: layers('protomaps', namedFlavor(theme), { lang: 'en' }), } } @@ -73,6 +74,7 @@ function deviceToGeoJSON(devices: FleetDevice[]): GeoJSON.FeatureCollection { export function FleetMap({ devices, tenantId }: FleetMapProps) { const containerRef = useRef(null) const mapRef = useRef(null) + const theme = useUIStore((s) => s.theme) const geojson = useMemo(() => deviceToGeoJSON(devices), [devices]) @@ -82,7 +84,7 @@ export function FleetMap({ devices, tenantId }: FleetMapProps) { const map = new maplibregl.Map({ container: containerRef.current, - style: buildMapStyle(), + style: buildMapStyle(theme), center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM, maxZoom: 17, @@ -228,6 +230,59 @@ export function FleetMap({ devices, tenantId }: FleetMapProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + // Re-add device layers after style change (setStyle wipes all sources/layers) + const addDeviceLayers = (map: maplibregl.Map) => { + if (map.getSource('devices')) return + map.addSource('devices', { + type: 'geojson', + data: geojson, + cluster: true, + clusterMaxZoom: 14, + clusterRadius: 50, + }) + map.addLayer({ + id: 'clusters', + type: 'circle', + source: 'devices', + filter: ['has', 'point_count'], + paint: { + 'circle-color': ['step', ['get', 'point_count'], '#22c55e', 10, '#f59e0b', 50, '#ef4444'], + 'circle-radius': ['step', ['get', 'point_count'], 18, 10, 24, 50, 32], + 'circle-stroke-width': 2, + 'circle-stroke-color': '#ffffff', + }, + }) + map.addLayer({ + id: 'cluster-count', + type: 'symbol', + source: 'devices', + filter: ['has', 'point_count'], + layout: { 'text-field': '{point_count_abbreviated}', 'text-font': ['Noto Sans Medium'], 'text-size': 13 }, + paint: { 'text-color': '#ffffff' }, + }) + map.addLayer({ + id: 'device-points', + type: 'circle', + source: 'devices', + filter: ['!', ['has', 'point_count']], + paint: { + 'circle-color': ['get', 'color'], + 'circle-radius': 7, + 'circle-stroke-width': 2, + 'circle-stroke-color': '#ffffff', + }, + }) + } + + // Switch map theme when app theme changes + useEffect(() => { + const map = mapRef.current + if (!map) return + map.setStyle(buildMapStyle(theme)) + map.once('styledata', () => addDeviceLayers(map)) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [theme]) + // Update device data when it changes useEffect(() => { const map = mapRef.current