mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-04-04 15:56:16 +02:00
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:
parent
8277e793ce
commit
dc94e7cdaf
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
116
admin/inertia/components/maps/MarkerPanel.tsx
Normal file
116
admin/inertia/components/maps/MarkerPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
admin/inertia/components/maps/MarkerPin.tsx
Normal file
17
admin/inertia/components/maps/MarkerPin.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
79
admin/inertia/hooks/useMapMarkers.ts
Normal file
79
admin/inertia/hooks/useMapMarkers.ts
Normal 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 }
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user