From f9af28fd33831968dcfb5ed284b4c40f1e73f6f9 Mon Sep 17 00:00:00 2001 From: Chris Sherwood Date: Thu, 2 Apr 2026 21:11:52 -0700 Subject: [PATCH] feat(maps): add scale bar, location markers with color selection Add distance scale bar and user-placed location pins to the offline maps viewer. - Scale bar (bottom-left) shows distance reference that updates with zoom level - Click anywhere on map to place a named pin with color selection (6 colors) - Collapsible "Saved Locations" panel lists all pins with fly-to navigation - Pins persist in localStorage across page loads - Full dark mode support for popups and panel via CSS overrides New files: useMapMarkers hook, MarkerPin component, MarkerPanel component No backend changes, no new dependencies. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../inertia/components/maps/MapComponent.tsx | 158 +++++++++++++++++- admin/inertia/components/maps/MarkerPanel.tsx | 116 +++++++++++++ admin/inertia/components/maps/MarkerPin.tsx | 17 ++ admin/inertia/css/app.css | 37 ++++ admin/inertia/hooks/useMapMarkers.ts | 79 +++++++++ 5 files changed, 405 insertions(+), 2 deletions(-) create mode 100644 admin/inertia/components/maps/MarkerPanel.tsx create mode 100644 admin/inertia/components/maps/MarkerPin.tsx create mode 100644 admin/inertia/hooks/useMapMarkers.ts diff --git a/admin/inertia/components/maps/MapComponent.tsx b/admin/inertia/components/maps/MapComponent.tsx index 5cc2d3b..a202538 100644 --- a/admin/inertia/components/maps/MapComponent.tsx +++ b/admin/inertia/components/maps/MapComponent.tsx @@ -1,10 +1,28 @@ -import Map, { FullscreenControl, NavigationControl, MapProvider } from 'react-map-gl/maplibre' +import Map, { + FullscreenControl, + NavigationControl, + ScaleControl, + Marker, + Popup, + MapProvider, +} from 'react-map-gl/maplibre' +import type { MapRef, MapLayerMouseEvent } from 'react-map-gl/maplibre' import maplibregl from 'maplibre-gl' import 'maplibre-gl/dist/maplibre-gl.css' import { Protocol } from 'pmtiles' -import { useEffect } from 'react' +import { useEffect, useRef, useState, useCallback } from 'react' +import { useMapMarkers, PIN_COLORS } from '~/hooks/useMapMarkers' +import type { PinColorId } from '~/hooks/useMapMarkers' +import MarkerPin from './MarkerPin' +import MarkerPanel from './MarkerPanel' export default function MapComponent() { + const mapRef = useRef(null) + const { markers, addMarker, deleteMarker } = useMapMarkers() + const [placingMarker, setPlacingMarker] = useState<{ lng: number; lat: number } | null>(null) + const [markerName, setMarkerName] = useState('') + const [markerColor, setMarkerColor] = useState('orange') + const [selectedMarkerId, setSelectedMarkerId] = useState(null) // Add the PMTiles protocol to maplibre-gl useEffect(() => { @@ -15,9 +33,40 @@ export default function MapComponent() { } }, []) + const handleMapClick = useCallback((e: MapLayerMouseEvent) => { + setPlacingMarker({ lng: e.lngLat.lng, lat: e.lngLat.lat }) + setMarkerName('') + setMarkerColor('orange') + setSelectedMarkerId(null) + }, []) + + const handleSaveMarker = useCallback(() => { + if (placingMarker && markerName.trim()) { + addMarker(markerName.trim(), placingMarker.lng, placingMarker.lat, markerColor) + setPlacingMarker(null) + setMarkerName('') + setMarkerColor('orange') + } + }, [placingMarker, markerName, markerColor, addMarker]) + + const handleFlyTo = useCallback((longitude: number, latitude: number) => { + mapRef.current?.flyTo({ center: [longitude, latitude], zoom: 12, duration: 1500 }) + }, []) + + const handleDeleteMarker = useCallback( + (id: string) => { + if (selectedMarkerId === id) setSelectedMarkerId(null) + deleteMarker(id) + }, + [selectedMarkerId, deleteMarker] + ) + + const selectedMarker = selectedMarkerId ? markers.find((m) => m.id === selectedMarkerId) : null + return ( + + + {/* Existing markers */} + {markers.map((marker) => ( + { + e.originalEvent.stopPropagation() + setSelectedMarkerId(marker.id === selectedMarkerId ? null : marker.id) + setPlacingMarker(null) + }} + > + c.id === marker.color)?.hex} + active={marker.id === selectedMarkerId} + /> + + ))} + + {/* Popup for selected marker */} + {selectedMarker && ( + setSelectedMarkerId(null)} + closeOnClick={false} + > +
{selectedMarker.name}
+
+ )} + + {/* Popup for placing a new marker */} + {placingMarker && ( + setPlacingMarker(null)} + closeOnClick={false} + > +
+ setMarkerName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveMarker() + if (e.key === 'Escape') setPlacingMarker(null) + }} + className="block w-full rounded border border-gray-300 px-2 py-1 text-sm placeholder:text-gray-400 focus:outline-none focus:border-gray-500" + /> +
+ {PIN_COLORS.map((c) => ( + + ))} +
+
+ + +
+
+
+ )}
+ + {/* Marker panel overlay */} +
) } diff --git a/admin/inertia/components/maps/MarkerPanel.tsx b/admin/inertia/components/maps/MarkerPanel.tsx new file mode 100644 index 0000000..de1982b --- /dev/null +++ b/admin/inertia/components/maps/MarkerPanel.tsx @@ -0,0 +1,116 @@ +import { useState } from 'react' +import { IconMapPinFilled, IconTrash, IconMapPin, IconX } from '@tabler/icons-react' +import { PIN_COLORS } from '~/hooks/useMapMarkers' +import type { MapMarker } from '~/hooks/useMapMarkers' + +interface MarkerPanelProps { + markers: MapMarker[] + onDelete: (id: string) => void + onFlyTo: (longitude: number, latitude: number) => void + onSelect: (id: string | null) => void + selectedMarkerId: string | null +} + +export default function MarkerPanel({ + markers, + onDelete, + onFlyTo, + onSelect, + selectedMarkerId, +}: MarkerPanelProps) { + const [open, setOpen] = useState(false) + + if (!open) { + return ( + + ) + } + + return ( +
+ {/* Header */} +
+
+ + + Saved Locations + + {markers.length > 0 && ( + + {markers.length} + + )} +
+ +
+ + {/* Marker list */} +
+ {markers.length === 0 ? ( +
+ +

+ Click anywhere on the map to drop a pin +

+
+ ) : ( +
    + {markers.map((marker) => ( +
  • + c.id === marker.color)?.hex ?? '#a84a12' }} + /> + + +
  • + ))} +
+ )} +
+
+ ) +} diff --git a/admin/inertia/components/maps/MarkerPin.tsx b/admin/inertia/components/maps/MarkerPin.tsx new file mode 100644 index 0000000..94c86af --- /dev/null +++ b/admin/inertia/components/maps/MarkerPin.tsx @@ -0,0 +1,17 @@ +import { IconMapPinFilled } from '@tabler/icons-react' + +interface MarkerPinProps { + color?: string + active?: boolean +} + +export default function MarkerPin({ color = '#a84a12', active = false }: MarkerPinProps) { + return ( +
+ +
+ ) +} diff --git a/admin/inertia/css/app.css b/admin/inertia/css/app.css index 74af512..9a7781d 100644 --- a/admin/inertia/css/app.css +++ b/admin/inertia/css/app.css @@ -118,4 +118,41 @@ body { --color-btn-green-active: #3a3c24; color-scheme: dark; +} + +/* MapLibre popup styling for dark mode */ +[data-theme="dark"] .maplibregl-popup-content { + background: #2a2918; + color: #f7eedc; +} + +[data-theme="dark"] .maplibregl-popup-content input { + background: #353420; + color: #f7eedc; + border-color: #424420; +} + +[data-theme="dark"] .maplibregl-popup-content input::placeholder { + color: #8f8f82; +} + +[data-theme="dark"] .maplibregl-popup-tip { + border-top-color: #2a2918; +} + +[data-theme="dark"] .maplibregl-popup-anchor-bottom .maplibregl-popup-tip { + border-top-color: #2a2918; +} + +[data-theme="dark"] .maplibregl-popup-anchor-top .maplibregl-popup-tip { + border-bottom-color: #2a2918; +} + +[data-theme="dark"] .maplibregl-popup-close-button { + color: #afafa5; +} + +[data-theme="dark"] .maplibregl-popup-close-button:hover { + color: #f7eedc; + background: #353420; } \ No newline at end of file diff --git a/admin/inertia/hooks/useMapMarkers.ts b/admin/inertia/hooks/useMapMarkers.ts new file mode 100644 index 0000000..509ce4e --- /dev/null +++ b/admin/inertia/hooks/useMapMarkers.ts @@ -0,0 +1,79 @@ +import { useState, useCallback } from 'react' + +export const PIN_COLORS = [ + { id: 'orange', label: 'Orange', hex: '#a84a12' }, + { id: 'red', label: 'Red', hex: '#994444' }, + { id: 'green', label: 'Green', hex: '#424420' }, + { id: 'blue', label: 'Blue', hex: '#2563eb' }, + { id: 'purple', label: 'Purple', hex: '#7c3aed' }, + { id: 'yellow', label: 'Yellow', hex: '#ca8a04' }, +] as const + +export type PinColorId = typeof PIN_COLORS[number]['id'] + +export interface MapMarker { + id: string + name: string + longitude: number + latitude: number + color: PinColorId + createdAt: string +} + +const STORAGE_KEY = 'nomad:map-markers' + +function getInitialMarkers(): MapMarker[] { + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) { + const parsed = JSON.parse(stored) + if (Array.isArray(parsed)) return parsed + } + } catch {} + return [] +} + +function persist(markers: MapMarker[]) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(markers)) + } catch {} +} + +export function useMapMarkers() { + const [markers, setMarkers] = useState(getInitialMarkers) + + const addMarker = useCallback((name: string, longitude: number, latitude: number, color: PinColorId = 'orange'): MapMarker => { + const marker: MapMarker = { + id: crypto.randomUUID(), + name, + longitude, + latitude, + color, + createdAt: new Date().toISOString(), + } + setMarkers((prev) => { + const next = [...prev, marker] + persist(next) + return next + }) + return marker + }, []) + + const updateMarker = useCallback((id: string, name: string) => { + setMarkers((prev) => { + const next = prev.map((m) => (m.id === id ? { ...m, name } : m)) + persist(next) + return next + }) + }, []) + + const deleteMarker = useCallback((id: string) => { + setMarkers((prev) => { + const next = prev.filter((m) => m.id !== id) + persist(next) + return next + }) + }, []) + + return { markers, addMarker, updateMarker, deleteMarker } +}