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) <noreply@anthropic.com>
This commit is contained in:
Chris Sherwood 2026-04-02 21:11:52 -07:00 committed by Jake Turner
parent 8277e793ce
commit dc94e7cdaf
No known key found for this signature in database
GPG Key ID: 6DCBBAE4FEAB53EB
5 changed files with 405 additions and 2 deletions

View File

@ -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<MapRef>(null)
const { markers, addMarker, deleteMarker } = useMapMarkers()
const [placingMarker, setPlacingMarker] = useState<{ lng: number; lat: number } | null>(null)
const [markerName, setMarkerName] = useState('')
const [markerColor, setMarkerColor] = useState<PinColorId>('orange')
const [selectedMarkerId, setSelectedMarkerId] = useState<string | null>(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 (
<MapProvider>
<Map
ref={mapRef}
reuseMaps
style={{
width: '100%',
@ -30,10 +79,115 @@ export default function MapComponent() {
latitude: 40,
zoom: 3.5,
}}
onClick={handleMapClick}
>
<NavigationControl style={{ marginTop: '110px', marginRight: '36px' }} />
<FullscreenControl style={{ marginTop: '30px', marginRight: '36px' }} />
<ScaleControl position="bottom-left" maxWidth={150} unit="imperial" />
{/* Existing markers */}
{markers.map((marker) => (
<Marker
key={marker.id}
longitude={marker.longitude}
latitude={marker.latitude}
anchor="bottom"
onClick={(e) => {
e.originalEvent.stopPropagation()
setSelectedMarkerId(marker.id === selectedMarkerId ? null : marker.id)
setPlacingMarker(null)
}}
>
<MarkerPin
color={PIN_COLORS.find((c) => c.id === marker.color)?.hex}
active={marker.id === selectedMarkerId}
/>
</Marker>
))}
{/* Popup for selected marker */}
{selectedMarker && (
<Popup
longitude={selectedMarker.longitude}
latitude={selectedMarker.latitude}
anchor="bottom"
offset={[0, -36] as [number, number]}
onClose={() => setSelectedMarkerId(null)}
closeOnClick={false}
>
<div className="text-sm font-medium">{selectedMarker.name}</div>
</Popup>
)}
{/* Popup for placing a new marker */}
{placingMarker && (
<Popup
longitude={placingMarker.lng}
latitude={placingMarker.lat}
anchor="bottom"
onClose={() => setPlacingMarker(null)}
closeOnClick={false}
>
<div className="p-1">
<input
autoFocus
type="text"
placeholder="Name this location"
value={markerName}
onChange={(e) => 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"
/>
<div className="mt-1.5 flex gap-1 items-center">
{PIN_COLORS.map((c) => (
<button
key={c.id}
onClick={() => setMarkerColor(c.id)}
title={c.label}
className="rounded-full p-0.5 transition-transform"
style={{
outline: markerColor === c.id ? `2px solid ${c.hex}` : '2px solid transparent',
outlineOffset: '1px',
}}
>
<div
className="w-4 h-4 rounded-full"
style={{ backgroundColor: c.hex }}
/>
</button>
))}
</div>
<div className="mt-1.5 flex gap-1.5 justify-end">
<button
onClick={() => setPlacingMarker(null)}
className="text-xs text-gray-500 hover:text-gray-700 px-2 py-1 rounded transition-colors"
>
Cancel
</button>
<button
onClick={handleSaveMarker}
disabled={!markerName.trim()}
className="text-xs bg-[#424420] text-white rounded px-2.5 py-1 hover:bg-[#525530] disabled:opacity-40 transition-colors"
>
Save
</button>
</div>
</div>
</Popup>
)}
</Map>
{/* Marker panel overlay */}
<MarkerPanel
markers={markers}
onDelete={handleDeleteMarker}
onFlyTo={handleFlyTo}
onSelect={setSelectedMarkerId}
selectedMarkerId={selectedMarkerId}
/>
</MapProvider>
)
}

View File

@ -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 (
<button
onClick={() => setOpen(true)}
className="absolute left-4 top-[72px] z-40 flex items-center gap-1.5 rounded-lg bg-surface-primary/95 px-3 py-2 shadow-lg border border-border-subtle backdrop-blur-sm hover:bg-surface-secondary transition-colors"
title="Show saved locations"
>
<IconMapPin size={18} className="text-desert-orange" />
<span className="text-sm font-medium text-text-primary">Pins</span>
{markers.length > 0 && (
<span className="ml-0.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-desert-orange text-[11px] font-bold text-white px-1">
{markers.length}
</span>
)}
</button>
)
}
return (
<div className="absolute left-4 top-[72px] z-40 w-72 rounded-lg bg-surface-primary/95 shadow-lg border border-border-subtle backdrop-blur-sm">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2.5 border-b border-border-subtle">
<div className="flex items-center gap-2">
<IconMapPin size={18} className="text-desert-orange" />
<span className="text-sm font-semibold text-text-primary">
Saved Locations
</span>
{markers.length > 0 && (
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-desert-orange text-[11px] font-bold text-white px-1">
{markers.length}
</span>
)}
</div>
<button
onClick={() => setOpen(false)}
className="rounded p-0.5 text-text-muted hover:text-text-primary hover:bg-surface-secondary transition-colors"
title="Close panel"
>
<IconX size={16} />
</button>
</div>
{/* Marker list */}
<div className="max-h-[calc(100vh-180px)] overflow-y-auto">
{markers.length === 0 ? (
<div className="px-3 py-6 text-center">
<IconMapPinFilled size={24} className="mx-auto mb-2 text-text-muted" />
<p className="text-sm text-text-muted">
Click anywhere on the map to drop a pin
</p>
</div>
) : (
<ul>
{markers.map((marker) => (
<li
key={marker.id}
className={`flex items-center gap-2 px-3 py-2 border-b border-border-subtle last:border-b-0 group transition-colors ${
marker.id === selectedMarkerId
? 'bg-desert-green/10'
: 'hover:bg-surface-secondary'
}`}
>
<IconMapPinFilled
size={16}
className="shrink-0"
style={{ color: PIN_COLORS.find((c) => c.id === marker.color)?.hex ?? '#a84a12' }}
/>
<button
onClick={() => {
onSelect(marker.id)
onFlyTo(marker.longitude, marker.latitude)
}}
className="flex-1 min-w-0 text-left"
title={marker.name}
>
<p className="text-sm font-medium text-text-primary truncate">
{marker.name}
</p>
</button>
<button
onClick={() => onDelete(marker.id)}
className="shrink-0 rounded p-1 text-text-muted opacity-0 group-hover:opacity-100 hover:text-desert-red hover:bg-surface-secondary transition-all"
title="Delete pin"
>
<IconTrash size={14} />
</button>
</li>
))}
</ul>
)}
</div>
</div>
)
}

View File

@ -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 (
<div className="cursor-pointer" style={{ filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.4))' }}>
<IconMapPinFilled
size={active ? 36 : 32}
style={{ color }}
/>
</div>
)
}

View File

@ -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;
}

View File

@ -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<MapMarker[]>(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 }
}