2026-04-29 22:47:24 +00:00
import { useEffect , useRef , forwardRef , useImperativeHandle , useState } from 'react'
import maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { Protocol } from 'pmtiles'
import { layers , namedTheme } from 'protomaps-themes-base'
import { useStore } from '../store'
import { decodePolyline } from '../utils/decode'
import { fetchReverse } from '../api'
import { getConfig , hasFeature } from '../config'
import { MapPin , Navigation , ArrowUpRight , ArrowDownLeft , Plus , Star , Ruler , X } from 'lucide-react'
import RadialMenu from './RadialMenu'
import useContextMenu from '../hooks/useContextMenu'
import toast from 'react-hot-toast'
const ROUTE _SOURCE = 'route-source'
const BOUNDARY _SOURCE = 'boundary-source'
const BOUNDARY _LAYER = 'boundary-layer'
const ROUTE _LAYER _PREFIX = 'route-layer-'
const HILLSHADE _SOURCE = 'hillshade-dem'
const HILLSHADE _LAYER = 'hillshade-layer'
const TRAFFIC _SOURCE = 'traffic-tiles'
const TRAFFIC _LAYER = 'traffic-layer'
const PUBLIC _LANDS _SOURCE = 'public-lands-tiles'
const PUBLIC _LANDS _FILL = 'public-lands-fill'
const PUBLIC _LANDS _LINE = 'public-lands-line'
const PUBLIC _LANDS _LABEL = 'public-lands-label'
const CONTOUR _SOURCE = 'contour-tiles'
const CONTOUR _MINOR = 'contour-minor'
const CONTOUR _INTERMEDIATE = 'contour-intermediate'
const CONTOUR _INDEX = 'contour-index'
const CONTOUR _LABEL = 'contour-label'
const CONTOUR _TEST _SOURCE = 'contour-test-tiles'
const CONTOUR _TEST _MINOR = 'contour-test-minor'
const CONTOUR _TEST _INTERMEDIATE = 'contour-test-intermediate'
const CONTOUR _TEST _INDEX = 'contour-test-index'
const CONTOUR _TEST _LABEL = 'contour-test-label'
const CONTOUR _TEST _10FT _SOURCE = 'contour-test-10ft-tiles'
const CONTOUR _TEST _10FT _MINOR = 'contour-test-10ft-minor'
const CONTOUR _TEST _10FT _INTERMEDIATE = 'contour-test-10ft-intermediate'
const CONTOUR _TEST _10FT _INDEX = 'contour-test-10ft-index'
const CONTOUR _TEST _10FT _LABEL = 'contour-test-10ft-label'
const MEASURE _SOURCE = 'measure-source'
const MEASURE _LINE _LAYER = 'measure-line-layer'
const MEASURE _POINT _LAYER = 'measure-point-layer'
// Highlight layers (filter-based for PMTiles compatibility)
const HIGHLIGHT _SOURCE _LAYERS = [ 'places' , 'pois' ]
const EMPTY _FILTER = [ '==' , [ 'get' , 'name' ] , '___NOMATCH___' ]
function setupHighlightLayers ( map , isDark ) {
const accentColor = getComputedStyle ( document . documentElement ) . getPropertyValue ( '--accent' ) . trim ( ) || '#7a9a6b'
HIGHLIGHT _SOURCE _LAYERS . forEach ( sl => {
if ( map . getLayer ( 'hover-hl-' + sl ) ) map . removeLayer ( 'hover-hl-' + sl )
if ( map . getLayer ( 'selected-hl-' + sl ) ) map . removeLayer ( 'selected-hl-' + sl )
} )
HIGHLIGHT _SOURCE _LAYERS . forEach ( sourceLayer => {
map . addLayer ( {
id : 'hover-hl-' + sourceLayer , type : 'symbol' , source : 'protomaps' , 'source-layer' : sourceLayer ,
filter : EMPTY _FILTER ,
layout : { 'text-field' : [ 'coalesce' , [ 'get' , 'name:en' ] , [ 'get' , 'name' ] ] , 'text-font' : [ 'Noto Sans Regular' ] , 'text-size' : [ 'interpolate' , [ 'linear' ] , [ 'zoom' ] , 4 , 10 , 10 , 14 , 16 , 18 ] , 'text-allow-overlap' : true , 'text-ignore-placement' : true } ,
paint : { 'text-color' : isDark ? '#ffffff' : '#000000' , 'text-halo-color' : isDark ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.2)' , 'text-halo-width' : 2.5 } ,
} )
map . addLayer ( {
id : 'selected-hl-' + sourceLayer , type : 'symbol' , source : 'protomaps' , 'source-layer' : sourceLayer ,
filter : EMPTY _FILTER ,
layout : { 'text-field' : [ 'coalesce' , [ 'get' , 'name:en' ] , [ 'get' , 'name' ] ] , 'text-font' : [ 'Noto Sans Regular' ] , 'text-size' : [ 'interpolate' , [ 'linear' ] , [ 'zoom' ] , 4 , 10 , 10 , 14 , 16 , 18 ] , 'text-allow-overlap' : true , 'text-ignore-placement' : true } ,
paint : { 'text-color' : accentColor , 'text-halo-color' : isDark ? 'rgba(122,154,107,0.5)' : 'rgba(122,154,107,0.3)' , 'text-halo-width' : 3 } ,
} )
} )
}
function setHoverHighlight ( map , feature ) {
HIGHLIGHT _SOURCE _LAYERS . forEach ( sl => { if ( map . getLayer ( 'hover-hl-' + sl ) ) map . setFilter ( 'hover-hl-' + sl , EMPTY _FILTER ) } )
if ( ! feature ) return
const name = feature . properties ? . name , sourceLayer = feature . sourceLayer
if ( name && sourceLayer && map . getLayer ( 'hover-hl-' + sourceLayer ) ) map . setFilter ( 'hover-hl-' + sourceLayer , [ '==' , [ 'get' , 'name' ] , name ] )
}
function setSelectedHighlight ( map , feature ) {
HIGHLIGHT _SOURCE _LAYERS . forEach ( sl => { if ( map . getLayer ( 'selected-hl-' + sl ) ) map . setFilter ( 'selected-hl-' + sl , EMPTY _FILTER ) } )
if ( ! feature ) return
const name = feature . properties ? . name , sourceLayer = feature . sourceLayer
if ( name && sourceLayer && map . getLayer ( 'selected-hl-' + sourceLayer ) ) map . setFilter ( 'selected-hl-' + sourceLayer , [ '==' , [ 'get' , 'name' ] , name ] )
}
/** Build a full MapLibre style object for the given theme */
function buildStyle ( themeName ) {
const config = getConfig ( )
const tileUrl = config ? . tileset ? . url || '/tiles/na.pmtiles'
const attribution = config ? . tileset ? . attribution || 'Protomaps \u00a9 OSM'
return {
version : 8 ,
glyphs : 'https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf' ,
sprite : ` https://protomaps.github.io/basemaps-assets/sprites/v4/ ${ themeName } ` ,
sources : {
protomaps : {
type : 'vector' ,
url : ` pmtiles:// ${ tileUrl } ` ,
attribution ,
} ,
} ,
layers : layers ( 'protomaps' , namedTheme ( themeName ) , { lang : 'en' } ) ,
}
}
/** SVG for ATAK-style chevron pointing up (will be rotated via CSS) */
/** Calculate haversine distance between two points in meters */
function haversineDistance ( lat1 , lon1 , lat2 , lon2 ) {
const R = 6371000 // Earth radius in meters
const dLat = ( lat2 - lat1 ) * Math . PI / 180
const dLon = ( lon2 - lon1 ) * Math . PI / 180
const a = Math . sin ( dLat / 2 ) * Math . sin ( dLat / 2 ) +
Math . cos ( lat1 * Math . PI / 180 ) * Math . cos ( lat2 * Math . PI / 180 ) *
Math . sin ( dLon / 2 ) * Math . sin ( dLon / 2 )
const c = 2 * Math . atan2 ( Math . sqrt ( a ) , Math . sqrt ( 1 - a ) )
return R * c
}
/** Format distance for display (feet/miles, imperial) */
function formatDistance ( meters ) {
const feet = meters * 3.28084
if ( feet < 1000 ) return Math . round ( feet ) + " ft"
const miles = feet / 5280
return miles < 10 ? miles . toFixed ( 2 ) + " mi" : miles . toFixed ( 1 ) + " mi"
}
const CHEVRON _SVG = ` <svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
< path d = "M8 1 L14 13 L8 10 L2 13 Z" fill = "var(--accent)" stroke = "var(--bg-raised)" stroke - width = "1.5" stroke - linejoin = "round" / >
< / svg > `
/** Add hillshade raster-dem source + layer to the map */
function addHillshade ( map ) {
if ( ! map || map . getSource ( HILLSHADE _SOURCE ) ) return
const config = getConfig ( )
const hs = config ? . tileset _hillshade
if ( ! hs ? . url ) return
map . addSource ( HILLSHADE _SOURCE , {
type : 'raster-dem' ,
url : ` pmtiles:// ${ hs . url } ` ,
encoding : hs . encoding || 'terrarium' ,
tileSize : 256 ,
maxzoom : hs . max _zoom || 12 ,
} )
// Insert below the first symbol/label layer for proper z-ordering
let beforeId = undefined
for ( const layer of map . getStyle ( ) . layers ) {
if ( layer . type === 'symbol' ) {
beforeId = layer . id
break
}
}
map . addLayer ( {
id : HILLSHADE _LAYER ,
type : 'hillshade' ,
source : HILLSHADE _SOURCE ,
paint : {
'hillshade-exaggeration' : 0.5 ,
'hillshade-illumination-direction' : 315 ,
'hillshade-shadow-color' : '#000000' ,
'hillshade-highlight-color' : '#ffffff' ,
} ,
} , beforeId )
}
/** Remove hillshade layer + source */
function removeHillshade ( map ) {
if ( ! map ) return
if ( map . getLayer ( HILLSHADE _LAYER ) ) map . removeLayer ( HILLSHADE _LAYER )
if ( map . getSource ( HILLSHADE _SOURCE ) ) map . removeSource ( HILLSHADE _SOURCE )
}
/** Add traffic raster tile source + layer */
function addTraffic ( map ) {
if ( ! map || map . getSource ( TRAFFIC _SOURCE ) ) return
const config = getConfig ( )
const tr = config ? . traffic
if ( ! tr ? . proxy _url ) return
const tileUrl = tr . proxy _url . replace ( '{z}' , '{z}' ) . replace ( '{x}' , '{x}' ) . replace ( '{y}' , '{y}' )
map . addSource ( TRAFFIC _SOURCE , {
type : 'raster' ,
tiles : [ tileUrl ] ,
tileSize : 256 ,
maxzoom : 18 ,
} )
map . addLayer ( {
id : TRAFFIC _LAYER ,
type : 'raster' ,
source : TRAFFIC _SOURCE ,
paint : {
'raster-opacity' : 0.6 ,
} ,
} )
}
/** Remove traffic layer + source */
function removeTraffic ( map ) {
if ( ! map ) return
if ( map . getLayer ( TRAFFIC _LAYER ) ) map . removeLayer ( TRAFFIC _LAYER )
if ( map . getSource ( TRAFFIC _SOURCE ) ) map . removeSource ( TRAFFIC _SOURCE )
}
/** Add public lands vector tile overlay (PAD-US) */
function addPublicLands ( map ) {
if ( ! map || map . getSource ( PUBLIC _LANDS _SOURCE ) ) return
map . addSource ( PUBLIC _LANDS _SOURCE , {
type : 'vector' ,
url : 'pmtiles:///tiles/public-lands.pmtiles' ,
} )
// Insert below symbol layers for proper z-ordering
let beforeId = undefined
for ( const layer of map . getStyle ( ) . layers ) {
if ( layer . type === 'symbol' ) {
beforeId = layer . id
break
}
}
const isDark = document . documentElement . getAttribute ( 'data-theme' ) === 'dark'
const opacityMod = isDark ? 0.7 : 1.0
// Fill layer — data-driven color by agency + designation
map . addLayer ( {
id : PUBLIC _LANDS _FILL ,
type : 'fill' ,
source : PUBLIC _LANDS _SOURCE ,
'source-layer' : 'public_lands' ,
paint : {
'fill-color' : [
'case' ,
[ '==' , [ 'get' , 'designation' ] , 'WA' ] , '#7c6b2f' ,
[ '==' , [ 'get' , 'designation' ] , 'WSA' ] , '#7c6b2f' ,
[ '==' , [ 'get' , 'agency' ] , 'NPS' ] , '#3d6b1f' ,
[ '==' , [ 'get' , 'agency' ] , 'USFS' ] , '#5a7c2f' ,
[ '==' , [ 'get' , 'agency' ] , 'BLM' ] , '#c4a672' ,
[ '==' , [ 'get' , 'agency' ] , 'FWS' ] , '#4a7a5a' ,
[ 'any' ,
[ '==' , [ 'get' , 'manager_type' ] , 'STAT' ] ,
[ '==' , [ 'get' , 'agency' ] , 'SPR' ] ,
[ '==' , [ 'get' , 'agency' ] , 'SDC' ] ,
[ '==' , [ 'get' , 'agency' ] , 'SLB' ]
] , '#5a8c7c' ,
[ 'any' ,
[ '==' , [ 'get' , 'manager_type' ] , 'LOC' ] ,
[ '==' , [ 'get' , 'manager_type' ] , 'DIST' ]
] , '#8ca694' ,
'#a0a0a0'
] ,
'fill-opacity' : [
'case' ,
[ '==' , [ 'get' , 'designation' ] , 'WA' ] , 0.30 * opacityMod ,
[ '==' , [ 'get' , 'designation' ] , 'WSA' ] , 0.30 * opacityMod ,
[ '==' , [ 'get' , 'agency' ] , 'NPS' ] , 0.30 * opacityMod ,
[ '==' , [ 'get' , 'agency' ] , 'USFS' ] , 0.25 * opacityMod ,
[ '==' , [ 'get' , 'agency' ] , 'BLM' ] , 0.20 * opacityMod ,
[ 'any' ,
[ '==' , [ 'get' , 'manager_type' ] , 'STAT' ] ,
[ '==' , [ 'get' , 'agency' ] , 'SPR' ]
] , 0.25 * opacityMod ,
[ 'any' ,
[ '==' , [ 'get' , 'manager_type' ] , 'LOC' ] ,
[ '==' , [ 'get' , 'manager_type' ] , 'DIST' ]
] , 0.20 * opacityMod ,
0.15 * opacityMod
] ,
} ,
} , beforeId )
// Outline layer
map . addLayer ( {
id : PUBLIC _LANDS _LINE ,
type : 'line' ,
source : PUBLIC _LANDS _SOURCE ,
'source-layer' : 'public_lands' ,
paint : {
'line-color' : [
'case' ,
[ '==' , [ 'get' , 'designation' ] , 'WA' ] , '#5a4d20' ,
[ '==' , [ 'get' , 'designation' ] , 'WSA' ] , '#5a4d20' ,
[ '==' , [ 'get' , 'agency' ] , 'NPS' ] , '#2a4a15' ,
[ '==' , [ 'get' , 'agency' ] , 'USFS' ] , '#3d5520' ,
[ '==' , [ 'get' , 'agency' ] , 'BLM' ] , '#8a7343' ,
[ '==' , [ 'get' , 'agency' ] , 'FWS' ] , '#2d5a3a' ,
[ 'any' ,
[ '==' , [ 'get' , 'manager_type' ] , 'STAT' ] ,
[ '==' , [ 'get' , 'agency' ] , 'SPR' ]
] , '#3d6055' ,
[ 'any' ,
[ '==' , [ 'get' , 'manager_type' ] , 'LOC' ] ,
[ '==' , [ 'get' , 'manager_type' ] , 'DIST' ]
] , '#5c6e66' ,
'#707070'
] ,
'line-opacity' : [
'case' ,
[ '==' , [ 'get' , 'agency' ] , 'NPS' ] , 0.7 ,
[ '==' , [ 'get' , 'agency' ] , 'USFS' ] , 0.6 ,
[ '==' , [ 'get' , 'agency' ] , 'BLM' ] , 0.5 ,
0.5
] ,
'line-width' : [
'interpolate' , [ 'linear' ] , [ 'zoom' ] ,
4 , 0.3 ,
8 , 0.8 ,
12 , 1.2
] ,
} ,
} , beforeId )
// Label layer — unit names at zoom 10+
map . addLayer ( {
id : PUBLIC _LANDS _LABEL ,
type : 'symbol' ,
source : PUBLIC _LANDS _SOURCE ,
'source-layer' : 'public_lands' ,
minzoom : 10 ,
layout : {
'text-field' : [ 'get' , 'name' ] ,
'text-size' : [ 'interpolate' , [ 'linear' ] , [ 'zoom' ] , 10 , 10 , 14 , 13 ] ,
'text-font' : [ 'Noto Sans Regular' ] ,
'symbol-placement' : 'point' ,
'text-anchor' : 'center' ,
'text-max-width' : 8 ,
'text-allow-overlap' : false ,
'text-ignore-placement' : false ,
} ,
paint : {
'text-color' : isDark ? '#c0c8b8' : '#3a4a30' ,
'text-halo-color' : isDark ? '#1a1a1a' : '#ffffff' ,
'text-halo-width' : 1.5 ,
'text-opacity' : 0.85 ,
} ,
} )
}
/** Remove public lands layers + source */
function removePublicLands ( map ) {
if ( ! map ) return
if ( map . getLayer ( PUBLIC _LANDS _LABEL ) ) map . removeLayer ( PUBLIC _LANDS _LABEL )
if ( map . getLayer ( PUBLIC _LANDS _LINE ) ) map . removeLayer ( PUBLIC _LANDS _LINE )
if ( map . getLayer ( PUBLIC _LANDS _FILL ) ) map . removeLayer ( PUBLIC _LANDS _FILL )
if ( map . getSource ( PUBLIC _LANDS _SOURCE ) ) map . removeSource ( PUBLIC _LANDS _SOURCE )
}
/** Add topographic contour vector tile overlay */
function addContours ( map ) {
if ( ! map || map . getSource ( CONTOUR _SOURCE ) ) return
map . addSource ( CONTOUR _SOURCE , {
type : 'vector' ,
url : 'pmtiles:///tiles/contours-na.pmtiles' ,
} )
// Insert below first symbol layer (above hillshade, below labels)
let beforeId = undefined
for ( const layer of map . getStyle ( ) . layers ) {
if ( layer . type === 'symbol' ) {
beforeId = layer . id
break
}
}
const isDark = document . documentElement . getAttribute ( 'data-theme' ) === 'dark'
const opMod = isDark ? 0.8 : 1.0
// Minor contours (40ft) — visible z11+
map . addLayer ( {
id : CONTOUR _MINOR ,
type : 'line' ,
source : CONTOUR _SOURCE ,
'source-layer' : 'contours' ,
minzoom : 11 ,
filter : [ '==' , [ 'get' , 'tier' ] , 'minor' ] ,
paint : {
'line-color' : '#8b6f47' ,
'line-opacity' : 0.4 * opMod ,
'line-width' : [ 'interpolate' , [ 'linear' ] , [ 'zoom' ] , 11 , 0.5 , 14 , 1.0 ] ,
} ,
} , beforeId )
// Intermediate contours (200ft) — visible z8+
map . addLayer ( {
id : CONTOUR _INTERMEDIATE ,
type : 'line' ,
source : CONTOUR _SOURCE ,
'source-layer' : 'contours' ,
minzoom : 8 ,
filter : [ '==' , [ 'get' , 'tier' ] , 'intermediate' ] ,
paint : {
'line-color' : '#8b6f47' ,
'line-opacity' : 0.7 * opMod ,
'line-width' : [ 'interpolate' , [ 'linear' ] , [ 'zoom' ] , 8 , 0.8 , 14 , 1.2 ] ,
} ,
} , beforeId )
// Index contours (1000ft) — visible z4+
map . addLayer ( {
id : CONTOUR _INDEX ,
type : 'line' ,
source : CONTOUR _SOURCE ,
'source-layer' : 'contours' ,
minzoom : 4 ,
filter : [ '==' , [ 'get' , 'tier' ] , 'index' ] ,
paint : {
'line-color' : '#6b4f2a' ,
'line-opacity' : 0.9 * opMod ,
'line-width' : [ 'interpolate' , [ 'linear' ] , [ 'zoom' ] , 4 , 1.2 , 14 , 1.8 ] ,
} ,
} , beforeId )
// Elevation labels on index contours (z12+)
map . addLayer ( {
id : CONTOUR _LABEL ,
type : 'symbol' ,
source : CONTOUR _SOURCE ,
'source-layer' : 'contours' ,
minzoom : 12 ,
filter : [ '==' , [ 'get' , 'tier' ] , 'index' ] ,
layout : {
'text-field' : [ 'concat' , [ 'to-string' , [ 'get' , 'elevation_ft' ] ] , "'" ] ,
'text-size' : 10 ,
'text-font' : [ 'Noto Sans Regular' ] ,
'symbol-placement' : 'line' ,
'text-anchor' : 'center' ,
'symbol-spacing' : 400 ,
'text-max-angle' : 30 ,
'text-allow-overlap' : false ,
} ,
paint : {
'text-color' : isDark ? '#c0b898' : '#5a4020' ,
'text-halo-color' : isDark ? '#1a1a1a' : '#ffffff' ,
'text-halo-width' : 1.5 ,
'text-opacity' : 0.85 ,
} ,
} )
}
/** Remove contour layers + source */
function removeContours ( map ) {
if ( ! map ) return
if ( map . getLayer ( CONTOUR _LABEL ) ) map . removeLayer ( CONTOUR _LABEL )
if ( map . getLayer ( CONTOUR _INDEX ) ) map . removeLayer ( CONTOUR _INDEX )
if ( map . getLayer ( CONTOUR _INTERMEDIATE ) ) map . removeLayer ( CONTOUR _INTERMEDIATE )
if ( map . getLayer ( CONTOUR _MINOR ) ) map . removeLayer ( CONTOUR _MINOR )
if ( map . getSource ( CONTOUR _SOURCE ) ) map . removeSource ( CONTOUR _SOURCE )
}
/** Add TEST topographic contour overlay (blue color scheme) */
function addContoursTest ( map ) {
if ( ! map || map . getSource ( CONTOUR _TEST _SOURCE ) ) return
map . addSource ( CONTOUR _TEST _SOURCE , {
type : "vector" ,
url : "pmtiles:///tiles/contours-test.pmtiles" ,
} )
let beforeId = undefined
for ( const layer of map . getStyle ( ) . layers ) {
if ( layer . type === "symbol" ) {
beforeId = layer . id
break
}
}
const isDark = document . documentElement . getAttribute ( "data-theme" ) === "dark"
const opMod = isDark ? 0.8 : 1.0
// Minor contours (40ft) — blue scheme
map . addLayer ( {
id : CONTOUR _TEST _MINOR ,
type : "line" ,
source : CONTOUR _TEST _SOURCE ,
"source-layer" : "contours" ,
minzoom : 11 ,
filter : [ "==" , [ "get" , "tier" ] , "minor" ] ,
paint : {
"line-color" : "#4a7c9b" ,
"line-opacity" : 0.4 * opMod ,
"line-width" : [ "interpolate" , [ "linear" ] , [ "zoom" ] , 11 , 0.5 , 14 , 1.0 ] ,
} ,
} , beforeId )
// Intermediate contours (200ft)
map . addLayer ( {
id : CONTOUR _TEST _INTERMEDIATE ,
type : "line" ,
source : CONTOUR _TEST _SOURCE ,
"source-layer" : "contours" ,
minzoom : 8 ,
filter : [ "==" , [ "get" , "tier" ] , "intermediate" ] ,
paint : {
"line-color" : "#4a7c9b" ,
"line-opacity" : 0.7 * opMod ,
"line-width" : [ "interpolate" , [ "linear" ] , [ "zoom" ] , 8 , 0.8 , 14 , 1.2 ] ,
} ,
} , beforeId )
// Index contours (1000ft)
map . addLayer ( {
id : CONTOUR _TEST _INDEX ,
type : "line" ,
source : CONTOUR _TEST _SOURCE ,
"source-layer" : "contours" ,
minzoom : 4 ,
filter : [ "==" , [ "get" , "tier" ] , "index" ] ,
paint : {
"line-color" : "#2a5a7c" ,
"line-opacity" : 0.9 * opMod ,
"line-width" : [ "interpolate" , [ "linear" ] , [ "zoom" ] , 4 , 1.2 , 14 , 1.8 ] ,
} ,
} , beforeId )
// Labels
map . addLayer ( {
id : CONTOUR _TEST _LABEL ,
type : "symbol" ,
source : CONTOUR _TEST _SOURCE ,
"source-layer" : "contours" ,
minzoom : 12 ,
filter : [ "==" , [ "get" , "tier" ] , "index" ] ,
layout : {
"text-field" : [ "concat" , [ "to-string" , [ "get" , "elevation_ft" ] ] , "" ] ,
"text-size" : 10 ,
"text-font" : [ "Noto Sans Regular" ] ,
"symbol-placement" : "line" ,
"text-anchor" : "center" ,
"symbol-spacing" : 400 ,
"text-max-angle" : 30 ,
"text-allow-overlap" : false ,
} ,
paint : {
"text-color" : isDark ? "#98b8d0" : "#205080" ,
"text-halo-color" : isDark ? "#1a1a1a" : "#ffffff" ,
"text-halo-width" : 1.5 ,
"text-opacity" : 0.85 ,
} ,
} )
}
/** Remove TEST contour layers + source */
function removeContoursTest ( map ) {
if ( ! map ) return
if ( map . getLayer ( CONTOUR _TEST _LABEL ) ) map . removeLayer ( CONTOUR _TEST _LABEL )
if ( map . getLayer ( CONTOUR _TEST _INDEX ) ) map . removeLayer ( CONTOUR _TEST _INDEX )
if ( map . getLayer ( CONTOUR _TEST _INTERMEDIATE ) ) map . removeLayer ( CONTOUR _TEST _INTERMEDIATE )
if ( map . getLayer ( CONTOUR _TEST _MINOR ) ) map . removeLayer ( CONTOUR _TEST _MINOR )
if ( map . getSource ( CONTOUR _TEST _SOURCE ) ) map . removeSource ( CONTOUR _TEST _SOURCE )
}
/** Add TEST 10ft topographic contour overlay (green color scheme) */
function addContoursTest10ft ( map ) {
if ( ! map || map . getSource ( CONTOUR _TEST _10FT _SOURCE ) ) return
map . addSource ( CONTOUR _TEST _10FT _SOURCE , {
type : "vector" ,
url : "pmtiles:///tiles/contours-test-10ft.pmtiles" ,
} )
let beforeId = undefined
for ( const layer of map . getStyle ( ) . layers ) {
if ( layer . type === "symbol" ) {
beforeId = layer . id
break
}
}
const isDark = document . documentElement . getAttribute ( "data-theme" ) === "dark"
const opMod = isDark ? 0.8 : 1.0
// Minor contours (10ft) — green scheme
map . addLayer ( {
id : CONTOUR _TEST _10FT _MINOR ,
type : "line" ,
source : CONTOUR _TEST _10FT _SOURCE ,
"source-layer" : "contours" ,
minzoom : 11 ,
filter : [ "==" , [ "get" , "tier" ] , "minor" ] ,
paint : {
"line-color" : "#3a7c4f" ,
"line-opacity" : 0.4 * opMod ,
"line-width" : [ "interpolate" , [ "linear" ] , [ "zoom" ] , 11 , 0.5 , 14 , 1.0 ] ,
} ,
} , beforeId )
// Intermediate contours (50ft) — green scheme
map . addLayer ( {
id : CONTOUR _TEST _10FT _INTERMEDIATE ,
type : "line" ,
source : CONTOUR _TEST _10FT _SOURCE ,
"source-layer" : "contours" ,
minzoom : 8 ,
filter : [ "==" , [ "get" , "tier" ] , "intermediate" ] ,
paint : {
"line-color" : "#3a7c4f" ,
"line-opacity" : 0.7 * opMod ,
"line-width" : [ "interpolate" , [ "linear" ] , [ "zoom" ] , 8 , 0.8 , 14 , 1.2 ] ,
} ,
} , beforeId )
// Index contours (250ft) — darker green
map . addLayer ( {
id : CONTOUR _TEST _10FT _INDEX ,
type : "line" ,
source : CONTOUR _TEST _10FT _SOURCE ,
"source-layer" : "contours" ,
minzoom : 4 ,
filter : [ "==" , [ "get" , "tier" ] , "index" ] ,
paint : {
"line-color" : "#2a5c3a" ,
"line-opacity" : 0.9 * opMod ,
"line-width" : [ "interpolate" , [ "linear" ] , [ "zoom" ] , 4 , 1.2 , 14 , 1.8 ] ,
} ,
} , beforeId )
// Elevation labels on index contours (z12+)
map . addLayer ( {
id : CONTOUR _TEST _10FT _LABEL ,
type : "symbol" ,
source : CONTOUR _TEST _10FT _SOURCE ,
"source-layer" : "contours" ,
minzoom : 12 ,
filter : [ "==" , [ "get" , "tier" ] , "index" ] ,
layout : {
"text-field" : [ "concat" , [ "to-string" , [ "get" , "elevation_ft" ] ] , "'" ] ,
"text-size" : 10 ,
"text-font" : [ "Noto Sans Regular" ] ,
"symbol-placement" : "line" ,
"text-anchor" : "center" ,
"symbol-spacing" : 400 ,
"text-max-angle" : 30 ,
"text-allow-overlap" : false ,
} ,
paint : {
"text-color" : isDark ? "#98c0a8" : "#2a4030" ,
"text-halo-color" : isDark ? "#1a1a1a" : "#ffffff" ,
"text-halo-width" : 1.5 ,
"text-opacity" : 0.85 ,
} ,
} )
}
/** Remove test 10ft contour layers + source */
function removeContoursTest10ft ( map ) {
if ( ! map ) return
if ( map . getLayer ( CONTOUR _TEST _10FT _LABEL ) ) map . removeLayer ( CONTOUR _TEST _10FT _LABEL )
if ( map . getLayer ( CONTOUR _TEST _10FT _INDEX ) ) map . removeLayer ( CONTOUR _TEST _10FT _INDEX )
if ( map . getLayer ( CONTOUR _TEST _10FT _INTERMEDIATE ) ) map . removeLayer ( CONTOUR _TEST _10FT _INTERMEDIATE )
if ( map . getLayer ( CONTOUR _TEST _10FT _MINOR ) ) map . removeLayer ( CONTOUR _TEST _10FT _MINOR )
if ( map . getSource ( CONTOUR _TEST _10FT _SOURCE ) ) map . removeSource ( CONTOUR _TEST _10FT _SOURCE )
}
/** Add boundary polygon layer with computed accent color (MapLibre rejects CSS vars in paint) */
function addBoundaryLayer ( map ) {
if ( ! map || map . getLayer ( BOUNDARY _LAYER ) ) return
if ( ! map . getSource ( BOUNDARY _SOURCE ) ) {
map . addSource ( BOUNDARY _SOURCE , {
type : "geojson" ,
data : { type : "FeatureCollection" , features : [ ] } ,
} )
}
const accentColor = getComputedStyle ( document . documentElement ) . getPropertyValue ( "--accent" ) . trim ( ) || "#7a9a6b"
map . addLayer ( {
id : BOUNDARY _LAYER ,
type : "line" ,
source : BOUNDARY _SOURCE ,
paint : {
"line-color" : accentColor ,
"line-width" : 2 ,
"line-opacity" : 0.7 ,
"line-dasharray" : [ 3 , 2 ] ,
} ,
} )
}
const MapView = forwardRef ( function MapView ( _ , ref ) {
const mapRef = useRef ( null )
const mapInstance = useRef ( null )
const markersRef = useRef ( [ ] )
const popupRef = useRef ( null )
const gpsMarkerRef = useRef ( null )
const previewMarkerRef = useRef ( null )
const watchIdRef = useRef ( null )
const currentThemeRef = useRef ( 'dark' )
// Track which overlay layers are currently active (for theme swap re-add)
const activeLayersRef = useRef ( { hillshade : false , traffic : false , contours : false , contoursTest : false , contoursTest10ft : false } )
// Flag to suppress map-click when a stop pin was clicked
const pinClickedRef = useRef ( false )
const highlightedFeatureRef = useRef ( null ) // { source, sourceLayer, id } for setFeatureState
const hoveredFeatureRef = useRef ( null ) // for hover highlight
2026-04-29 23:23:11 +00:00
const updateBoundaryRef = useRef ( null ) // boundary update function
2026-04-29 22:47:24 +00:00
// Refs for measurement state (accessible in click handlers)
const measuringRef = useRef ( { active : false , points : [ ] } )
const measureLabelsRef = useRef ( [ ] ) // HTML label elements
const stops = useStore ( ( s ) => s . stops )
const route = useStore ( ( s ) => s . route )
const theme = useStore ( ( s ) => s . theme )
const selectedPlace = useStore ( ( s ) => s . selectedPlace )
const clickMarker = useStore ( ( s ) => s . clickMarker )
const setClickMarker = useStore ( ( s ) => s . setClickMarker )
const clearClickMarker = useStore ( ( s ) => s . clearClickMarker )
const gpsOrigin = useStore ( ( s ) => s . gpsOrigin )
const geoPermission = useStore ( ( s ) => s . geoPermission )
const setSheetState = useStore ( ( s ) => s . setSheetState )
const setMapCenter = useStore ( ( s ) => s . setMapCenter )
const pickingLocationFor = useStore ( ( s ) => s . pickingLocationFor )
const setEditingContact = useStore ( ( s ) => s . setEditingContact )
const clearPickingLocationFor = useStore ( ( s ) => s . clearPickingLocationFor )
// Zoom level indicator state
const [ zoomLevel , setZoomLevel ] = useState ( 10 )
// Radial menu state
const [ radialMenu , setRadialMenu ] = useState ( {
open : false ,
x : 0 ,
y : 0 ,
lat : 0 ,
lon : 0 ,
centerLabel : null ,
} )
// Measurement mode state (for UI rendering)
const [ measuring , setMeasuring ] = useState ( { active : false , points : [ ] , totalMeters : 0 } )
// Sync state to ref for click handler access
const updateMeasuringState = ( newState ) => {
measuringRef . current = newState
setMeasuring ( newState )
}
// Update measurement layer with current points
const updateMeasureLayer = ( points ) => {
const map = mapInstance . current
if ( ! map || ! map . getSource ( MEASURE _SOURCE ) ) return
const features = [ ]
// Add points
points . forEach ( ( p , i ) => {
features . push ( {
type : "Feature" ,
geometry : { type : "Point" , coordinates : [ p . lon , p . lat ] } ,
properties : { index : i } ,
} )
} )
// Add line if more than one point
if ( points . length > 1 ) {
features . push ( {
type : "Feature" ,
geometry : {
type : "LineString" ,
coordinates : points . map ( ( p ) => [ p . lon , p . lat ] ) ,
} ,
properties : { } ,
} )
}
map . getSource ( MEASURE _SOURCE ) . setData ( {
type : "FeatureCollection" ,
features ,
} )
}
// Update segment labels (HTML overlays)
const updateMeasureLabels = ( points ) => {
const map = mapInstance . current
if ( ! map ) return
// Remove old labels
measureLabelsRef . current . forEach ( el => el . remove ( ) )
measureLabelsRef . current = [ ]
if ( points . length < 2 ) return
const container = mapRef . current
if ( ! container ) return
// Create label for each segment
for ( let i = 1 ; i < points . length ; i ++ ) {
const p1 = points [ i - 1 ]
const p2 = points [ i ]
const midLat = ( p1 . lat + p2 . lat ) / 2
const midLon = ( p1 . lon + p2 . lon ) / 2
const dist = haversineDistance ( p1 . lat , p1 . lon , p2 . lat , p2 . lon )
const label = document . createElement ( 'div' )
label . className = 'measure-label'
label . textContent = formatDistance ( dist )
label . style . cssText = `
position : absolute ;
background : rgba ( 0 , 0 , 0 , 0.75 ) ;
color : white ;
padding : 2 px 6 px ;
border - radius : 10 px ;
font - size : 11 px ;
font - weight : 500 ;
pointer - events : none ;
white - space : nowrap ;
z - index : 100 ;
transform : translate ( - 50 % , - 50 % ) ;
`
const pos = map . project ( [ midLon , midLat ] )
label . style . left = pos . x + 'px'
label . style . top = pos . y + 'px'
container . appendChild ( label )
measureLabelsRef . current . push ( label )
}
}
// Reposition labels on map move/zoom
const repositionLabels = ( ) => {
const map = mapInstance . current
const points = measuringRef . current . points
if ( ! map || points . length < 2 ) return
measureLabelsRef . current . forEach ( ( label , i ) => {
if ( i >= points . length - 1 ) return
const p1 = points [ i ]
const p2 = points [ i + 1 ]
const midLat = ( p1 . lat + p2 . lat ) / 2
const midLon = ( p1 . lon + p2 . lon ) / 2
const pos = map . project ( [ midLon , midLat ] )
label . style . left = pos . x + 'px'
label . style . top = pos . y + 'px'
} )
}
// Clear measurement mode completely
const clearMeasuring = ( ) => {
const map = mapInstance . current
updateMeasuringState ( { active : false , points : [ ] , totalMeters : 0 } )
// Remove labels
measureLabelsRef . current . forEach ( el => el . remove ( ) )
measureLabelsRef . current = [ ]
if ( map ) {
map . getCanvas ( ) . style . cursor = ""
map . doubleClickZoom . enable ( )
if ( map . getLayer ( MEASURE _LINE _LAYER ) ) map . removeLayer ( MEASURE _LINE _LAYER )
if ( map . getLayer ( MEASURE _POINT _LAYER ) ) map . removeLayer ( MEASURE _POINT _LAYER )
if ( map . getSource ( MEASURE _SOURCE ) ) map . removeSource ( MEASURE _SOURCE )
}
}
// End measurement (keep line visible, exit active mode)
const endMeasuring = ( ) => {
const map = mapInstance . current
if ( map ) {
map . getCanvas ( ) . style . cursor = ""
map . doubleClickZoom . enable ( )
}
updateMeasuringState ( { ... measuringRef . current , active : false } )
}
// Start new measurement
const startMeasuring = ( lat , lon ) => {
const map = mapInstance . current
if ( ! map ) return
// Clear any existing measurement first
measureLabelsRef . current . forEach ( el => el . remove ( ) )
measureLabelsRef . current = [ ]
if ( map . getLayer ( MEASURE _LINE _LAYER ) ) map . removeLayer ( MEASURE _LINE _LAYER )
if ( map . getLayer ( MEASURE _POINT _LAYER ) ) map . removeLayer ( MEASURE _POINT _LAYER )
if ( map . getSource ( MEASURE _SOURCE ) ) map . removeSource ( MEASURE _SOURCE )
// Set up new measurement
updateMeasuringState ( { active : true , points : [ { lat , lon } ] , totalMeters : 0 } )
map . getCanvas ( ) . style . cursor = "crosshair"
map . doubleClickZoom . disable ( )
// Add source and layers
map . addSource ( MEASURE _SOURCE , {
type : "geojson" ,
data : { type : "FeatureCollection" , features : [ ] } ,
} )
const accentColor = getComputedStyle ( document . documentElement ) . getPropertyValue ( "--accent" ) . trim ( ) || "#7a9a6b"
map . addLayer ( {
id : MEASURE _LINE _LAYER ,
type : "line" ,
source : MEASURE _SOURCE ,
paint : {
"line-color" : accentColor ,
"line-width" : 2 ,
"line-dasharray" : [ 8 , 4 ] ,
} ,
} )
map . addLayer ( {
id : MEASURE _POINT _LAYER ,
type : "circle" ,
source : MEASURE _SOURCE ,
filter : [ "==" , "$type" , "Point" ] ,
paint : {
"circle-radius" : 5 ,
"circle-color" : accentColor ,
"circle-stroke-width" : 2 ,
"circle-stroke-color" : "#1a1a1a" ,
} ,
} )
updateMeasureLayer ( [ { lat , lon } ] )
}
// Add a point to the measurement
const addMeasurePoint = ( lat , lon ) => {
const current = measuringRef . current
if ( ! current . active ) return
const newPoints = [ ... current . points , { lat , lon } ]
// Calculate total distance
let totalMeters = 0
for ( let i = 1 ; i < newPoints . length ; i ++ ) {
totalMeters += haversineDistance (
newPoints [ i - 1 ] . lat , newPoints [ i - 1 ] . lon ,
newPoints [ i ] . lat , newPoints [ i ] . lon
)
}
updateMeasuringState ( { active : true , points : newPoints , totalMeters } )
updateMeasureLayer ( newPoints )
updateMeasureLabels ( newPoints )
}
const radialWedges = [
{
id : "directions-to" ,
label : "To here" ,
icon : ArrowDownLeft ,
onSelect : ( ) => {
setRadialMenu ( ( m ) => ( { ... m , open : false } ) )
const place = {
lat : radialMenu . lat ,
lon : radialMenu . lon ,
name : radialMenu . centerLabel || radialMenu . lat . toFixed ( 5 ) + ", " + radialMenu . lon . toFixed ( 5 ) ,
source : "radial_menu" ,
matchCode : null ,
}
useStore . getState ( ) . startDirections ( place )
} ,
} ,
{
id : "directions-from" ,
label : "From here" ,
icon : ArrowUpRight ,
onSelect : ( ) => {
setRadialMenu ( ( m ) => ( { ... m , open : false } ) )
const { clearStops , addStop } = useStore . getState ( )
clearStops ( )
const place = {
lat : radialMenu . lat ,
lon : radialMenu . lon ,
name : radialMenu . centerLabel || radialMenu . lat . toFixed ( 5 ) + ", " + radialMenu . lon . toFixed ( 5 ) ,
source : "radial_menu" ,
matchCode : null ,
}
addStop ( place )
useStore . setState ( { gpsOrigin : false } )
} ,
} ,
{
id : "add-stop" ,
label : "Add stop" ,
icon : Plus ,
onSelect : ( ) => {
setRadialMenu ( ( m ) => ( { ... m , open : false } ) )
const { stops , addStop , clearStops } = useStore . getState ( )
const place = {
lat : radialMenu . lat ,
lon : radialMenu . lon ,
name : radialMenu . centerLabel || radialMenu . lat . toFixed ( 5 ) + ", " + radialMenu . lon . toFixed ( 5 ) ,
source : "radial_menu" ,
matchCode : null ,
}
if ( stops . length === 0 ) {
addStop ( place )
useStore . setState ( { gpsOrigin : false } )
} else {
const success = addStop ( place )
if ( ! success ) {
toast ( "Maximum 10 stops reached" )
}
}
} ,
} ,
{
id : "save-place" ,
label : "Save" ,
icon : Star ,
requiresAuth : true ,
onSelect : ( ) => {
setRadialMenu ( ( m ) => ( { ... m , open : false } ) )
const { auth , setEditingContact } = useStore . getState ( )
if ( auth . authenticated ) {
setEditingContact ( {
label : "" ,
lat : radialMenu . lat ,
lon : radialMenu . lon ,
} )
} else {
toast ( "Log in to save places" )
}
} ,
} ,
{
id : "measure" ,
label : "Measure" ,
icon : Ruler ,
onSelect : ( ) => {
setRadialMenu ( ( m ) => ( { ... m , open : false } ) )
startMeasuring ( radialMenu . lat , radialMenu . lon )
} ,
} ,
]
// Context menu trigger handler
const handleContextMenuTrigger = ( { x , y } ) => {
const map = mapInstance . current
if ( ! map || ! mapRef . current ) return
// Suppress context menu during measurement mode
if ( measuringRef . current . active ) return
// Convert screen coords to lat/lon
const rect = mapRef . current . getBoundingClientRect ( )
const lngLat = map . unproject ( [ x - rect . left , y - rect . top ] )
setRadialMenu ( {
open : true ,
x ,
y ,
lat : lngLat . lat ,
lon : lngLat . lng ,
centerLabel : null ,
} )
// Async reverse geocode for center label
fetchReverse ( lngLat . lat , lngLat . lng ) . then ( ( place ) => {
if ( place ) {
setRadialMenu ( ( m ) => {
if ( m . open && Math . abs ( m . lat - lngLat . lat ) < 0.00001 ) {
return { ... m , centerLabel : place . name }
}
return m
} )
}
} )
}
// Context menu hook
const contextMenuHandlers = useContextMenu ( handleContextMenuTrigger )
useImperativeHandle ( ref , ( ) => ( {
flyTo ( lat , lon , zoom = 14 ) {
mapInstance . current ? . flyTo ( { center : [ lon , lat ] , zoom } )
} ,
getMap ( ) {
return mapInstance . current
} ,
addHillshadeLayer ( ) {
const map = mapInstance . current
if ( ! map ) return
addHillshade ( map )
activeLayersRef . current . hillshade = true
} ,
removeHillshadeLayer ( ) {
const map = mapInstance . current
if ( ! map ) return
removeHillshade ( map )
activeLayersRef . current . hillshade = false
} ,
addTrafficLayer ( ) {
const map = mapInstance . current
if ( ! map ) return
addTraffic ( map )
activeLayersRef . current . traffic = true
} ,
removeTrafficLayer ( ) {
const map = mapInstance . current
if ( ! map ) return
removeTraffic ( map )
activeLayersRef . current . traffic = false
} ,
addPublicLandsLayer ( ) {
const map = mapInstance . current
if ( ! map ) return
addPublicLands ( map )
activeLayersRef . current . publicLands = true
} ,
removePublicLandsLayer ( ) {
const map = mapInstance . current
if ( ! map ) return
removePublicLands ( map )
activeLayersRef . current . publicLands = false
} ,
addContoursLayer ( ) {
const map = mapInstance . current
if ( ! map ) return
addContours ( map )
activeLayersRef . current . contours = true
} ,
removeContoursLayer ( ) {
const map = mapInstance . current
if ( ! map ) return
removeContours ( map )
activeLayersRef . current . contours = false
} ,
addContoursTestLayer ( ) {
const map = mapInstance . current
if ( ! map ) return
addContoursTest ( map )
activeLayersRef . current . contoursTest = true
} ,
removeContoursTestLayer ( ) {
const map = mapInstance . current
if ( ! map ) return
removeContoursTest ( map )
activeLayersRef . current . contoursTest = false
} ,
addContoursTest10ftLayer ( ) {
const map = mapInstance . current
if ( ! map ) return
addContoursTest10ft ( map )
activeLayersRef . current . contoursTest10ft = true
} ,
removeContoursTest10ftLayer ( ) {
const map = mapInstance . current
if ( ! map ) return
removeContoursTest10ft ( map )
activeLayersRef . current . contoursTest10ft = false
} ,
} ) )
// Initialize map
useEffect ( ( ) => {
const protocol = new Protocol ( )
maplibregl . addProtocol ( 'pmtiles' , protocol . tile )
const config = getConfig ( )
const DEFAULT _CENTER = config ? . defaults ? . center
? [ config . defaults . center [ 1 ] , config . defaults . center [ 0 ] ] // config is [lat,lon], MapLibre wants [lon,lat]
: [ - 114.6066 , 42.5736 ]
const DEFAULT _ZOOM = config ? . defaults ? . zoom || 10
const initialTheme = document . documentElement . getAttribute ( 'data-theme' ) || 'dark'
currentThemeRef . current = initialTheme
const map = new maplibregl . Map ( {
container : mapRef . current ,
style : buildStyle ( initialTheme ) ,
center : DEFAULT _CENTER ,
zoom : DEFAULT _ZOOM ,
} )
map . addControl ( new maplibregl . NavigationControl ( ) , 'top-right' )
// Scale bar control
map . addControl ( new maplibregl . ScaleControl ( {
maxWidth : 120 ,
unit : 'imperial' ,
} ) , 'bottom-right' )
// Map click — two-click selection model
map . on ( 'click' , ( e ) => {
// If a stop pin was just clicked, skip
if ( pinClickedRef . current ) {
pinClickedRef . current = false
return
}
// CRITICAL: Check measuring mode FIRST using ref (not stale closure)
if ( measuringRef . current . active ) {
const { lng , lat } = e . lngLat
addMeasurePoint ( lat , lng )
return
}
// Handle location pick mode for contacts
const pickState = useStore . getState ( ) . pickingLocationFor
if ( pickState ) {
const { lng , lat } = e . lngLat
map . getCanvas ( ) . style . cursor = ''
// Reverse geocode for address
fetchReverse ( lat , lng ) . then ( ( place ) => {
const addr = place ? . address || place ? . name || ''
// Rebuild form data with new location
useStore . getState ( ) . setEditingContact ( {
... pickState ,
lat ,
lon : lng ,
address : addr || pickState . address || '' ,
} )
useStore . getState ( ) . clearPickingLocationFor ( )
} ) . catch ( ( ) => {
// Even if reverse geocode fails, set the location
useStore . getState ( ) . setEditingContact ( {
... pickState ,
lat ,
lon : lng ,
} )
useStore . getState ( ) . clearPickingLocationFor ( )
} )
return
}
const store = useStore . getState ( )
const marker = store . clickMarker
if ( marker ) {
// State B: marker present — check if click is inside the circle
const markerScreen = map . project ( [ marker . lon , marker . lat ] )
const dx = e . point . x - markerScreen . x
const dy = e . point . y - markerScreen . y
const dist = Math . sqrt ( dx * dx + dy * dy )
if ( dist <= marker . circleRadiusPx ) {
// Inside circle → open radial at marker location
const rect = mapRef . current ? . getBoundingClientRect ( )
const screenX = rect ? markerScreen . x + rect . left : markerScreen . x
const screenY = rect ? markerScreen . y + rect . top : markerScreen . y
setRadialMenu ( {
open : true ,
x : screenX ,
y : screenY ,
lat : marker . lat ,
lon : marker . lon ,
centerLabel : store . selectedPlace ? . name || null ,
} )
// Fetch reverse geocode for center label if not already loaded
if ( ! store . selectedPlace ? . name || store . selectedPlace . name === 'Dropped pin' ) {
fetchReverse ( marker . lat , marker . lon ) . then ( ( place ) => {
if ( place ) {
setRadialMenu ( ( m ) => {
if ( m . open && Math . abs ( m . lat - marker . lat ) < 0.00001 ) {
return { ... m , centerLabel : place . name }
}
return m
} )
}
} )
}
} else {
// Outside circle → deselect, no new selection
store . clearClickMarker ( )
store . clearSelectedPlace ( )
2026-04-29 23:07:41 +00:00
// Clear boundary when deselecting
2026-04-29 23:23:11 +00:00
if ( updateBoundaryRef . current ) updateBoundaryRef . current ( null )
2026-04-29 23:07:41 +00:00
setSelectedHighlight ( map , null )
2026-04-29 22:47:24 +00:00
}
} else {
// State A: nothing selected → select
if ( window . innerWidth < 768 ) setSheetState ( 'collapsed' )
const { lng , lat } = e . lngLat
const MARKER _RADIUS _PX = 14 // half of 28px preview marker
// Query rendered features at click point (label/POI priority)
const labelLayers = [ 'pois' , 'places_subplace' , 'places_locality' , 'places_region' , 'places_country' ]
const features = map . queryRenderedFeatures ( e . point , { layers : labelLayers } )
// Find first feature with a name (respects layer order = priority)
const labelFeature = features . find ( f => f . properties ? . name )
2026-04-29 23:07:41 +00:00
// Clear previous feature highlight and boundary
2026-04-29 22:47:24 +00:00
if ( highlightedFeatureRef . current ) {
const { source , sourceLayer , id } = highlightedFeatureRef . current
try {
map . setFeatureState ( { source , sourceLayer , id } , { selected : false } )
} catch ( e ) { /* ignore if layer removed */ }
highlightedFeatureRef . current = null
}
setSelectedHighlight ( map , null )
2026-04-29 23:07:41 +00:00
// Clear old boundary before setting new place
2026-04-29 23:23:11 +00:00
if ( updateBoundaryRef . current ) updateBoundaryRef . current ( null )
2026-04-29 22:47:24 +00:00
if ( labelFeature ) {
// Clicked a labeled feature — snap to geometry and highlight
const props = labelFeature . properties
const geom = labelFeature . geometry
// Get feature coordinates (Point geometry)
let featureLat = lat
let featureLon = lng
if ( geom && geom . type === 'Point' && geom . coordinates ) {
featureLon = geom . coordinates [ 0 ]
featureLat = geom . coordinates [ 1 ]
}
// Apply feature state highlight
const featureId = labelFeature . id ? ? props . mvt _id
const sourceLayer = labelFeature . sourceLayer
const source = labelFeature . source
if ( featureId != null && source ) {
try {
map . setFeatureState ( { source , sourceLayer , id : featureId } , { selected : true } )
highlightedFeatureRef . current = { source , sourceLayer , id : featureId }
} catch ( e ) { console . warn ( 'setFeatureState error:' , e ) }
}
// Filter-based highlight (works with PMTiles)
setSelectedHighlight ( map , labelFeature )
setHoverHighlight ( map , null )
// For feature clicks, don't show pin marker
store . clearClickMarker ( )
store . setSelectedPlace ( {
lat : featureLat ,
lon : featureLon ,
name : props . name || 'Unknown' ,
address : null ,
type : props . kind _detail || props . kind || null ,
source : 'basemap_label' ,
matchCode : null ,
mode : 'feature' ,
featureId : featureId ,
featureLayer : labelFeature . layer ? . id || null ,
wikidata : props . wikidata || null ,
raw : {
wikidata : props . wikidata || null ,
population : props . population || null ,
kind : props . kind || null ,
kind _detail : props . kind _detail || null ,
elevation : props . elevation || null ,
} ,
} )
} else {
// No labeled feature — show reticle at click point
2026-04-29 23:07:41 +00:00
// Clear any existing boundary when clicking empty map
2026-04-29 23:23:11 +00:00
if ( updateBoundaryRef . current ) updateBoundaryRef . current ( null )
2026-04-29 22:47:24 +00:00
store . setClickMarker ( {
lat ,
lon : lng ,
circleRadiusPx : MARKER _RADIUS _PX ,
} )
store . setSelectedPlace ( {
lat ,
lon : lng ,
name : 'Dropped pin' ,
address : null ,
type : null ,
source : 'map_click' ,
matchCode : null ,
mode : 'reticle' ,
raw : { } ,
} )
// Reverse geocode in background
fetchReverse ( lat , lng ) . then ( ( place ) => {
if ( ! place ) return
const current = useStore . getState ( ) . selectedPlace
if ( current && Math . abs ( current . lat - lat ) < 0.00001 && Math . abs ( current . lon - lng ) < 0.00001 ) {
useStore . getState ( ) . setSelectedPlace ( {
... place ,
lat ,
lon : lng ,
} )
}
} )
}
}
} )
// Double-click ends measurement mode (and prevents zoom)
map . on ( 'dblclick' , ( e ) => {
if ( measuringRef . current . active ) {
e . preventDefault ( )
// Add final point and end
const { lng , lat } = e . lngLat
addMeasurePoint ( lat , lng )
endMeasuring ( )
}
} )
// Reposition measure labels on map move
map . on ( 'move' , repositionLabels )
// Initialize mapCenter immediately when map loads (Fix 1: search viewport)
map . once ( 'load' , ( ) => {
const center = map . getCenter ( )
const zoom = map . getZoom ( )
setMapCenter ( { lat : center . lat , lon : center . lng , zoom } )
} )
map . on ( 'load' , ( ) => {
// Guard against double-mount in React strict mode
if ( ! map . getSource ( ROUTE _SOURCE ) ) {
map . addSource ( ROUTE _SOURCE , {
type : 'geojson' ,
data : { type : 'FeatureCollection' , features : [ ] } ,
} )
}
// Boundary polygon layer for selected places
if ( ! map . getLayer ( BOUNDARY _LAYER ) ) {
addBoundaryLayer ( map )
}
// Restore overlay layers from localStorage prefs
try {
const raw = localStorage . getItem ( 'navi-layer-prefs' )
if ( raw ) {
const prefs = JSON . parse ( raw )
if ( prefs . hillshade && hasFeature ( 'has_hillshade' ) ) {
addHillshade ( map )
activeLayersRef . current . hillshade = true
}
if ( prefs . traffic && hasFeature ( 'has_traffic_overlay' ) ) {
addTraffic ( map )
activeLayersRef . current . traffic = true
}
if ( prefs . publicLands && hasFeature ( 'has_public_lands_layer' ) ) {
addPublicLands ( map )
activeLayersRef . current . publicLands = true
}
if ( prefs . contours && hasFeature ( 'has_contours' ) ) {
addContours ( map )
activeLayersRef . current . contours = true
}
} else if ( hasFeature ( 'has_hillshade' ) ) {
// Default: hillshade ON if available
addHillshade ( map )
activeLayersRef . current . hillshade = true
}
} catch { }
// Set up highlight layers
setupHighlightLayers ( map , document . documentElement . getAttribute ( 'data-theme' ) === 'dark' )
2026-04-29 23:23:11 +00:00
// Register updateBoundary function - called directly when boundary data arrives
const updateBoundaryFn = ( boundaryGeometry ) => {
const source = map . getSource ( BOUNDARY _SOURCE )
if ( ! source ) return
if ( ! boundaryGeometry ) {
source . setData ( { type : 'FeatureCollection' , features : [ ] } )
return
}
if ( boundaryGeometry . type === 'Polygon' || boundaryGeometry . type === 'MultiPolygon' ) {
source . setData ( {
type : 'Feature' ,
geometry : boundaryGeometry ,
properties : { } ,
} )
// Zoom to fit boundary
try {
const coords = boundaryGeometry . type === 'Polygon'
? boundaryGeometry . coordinates [ 0 ]
: boundaryGeometry . coordinates . flat ( 1 )
if ( coords . length > 0 ) {
let minLng = Infinity , maxLng = - Infinity , minLat = Infinity , maxLat = - Infinity
for ( const [ lng , lat ] of coords ) {
if ( lng < minLng ) minLng = lng
if ( lng > maxLng ) maxLng = lng
if ( lat < minLat ) minLat = lat
if ( lat > maxLat ) maxLat = lat
}
map . fitBounds ( [ [ minLng , minLat ] , [ maxLng , maxLat ] ] , {
padding : 50 ,
duration : 700 ,
maxZoom : 16 ,
} )
}
} catch ( e ) {
console . warn ( 'fitBounds error:' , e )
}
}
}
updateBoundaryRef . current = updateBoundaryFn
useStore . getState ( ) . setUpdateBoundary ( updateBoundaryFn )
2026-04-29 22:47:24 +00:00
// POI/label hover affordance — cursor pointer + highlight
const interactiveLayers = [ 'pois' , 'places_locality' , 'places_region' , 'places_country' , 'places_subplace' ]
interactiveLayers . forEach ( layerId => {
map . on ( 'mouseenter' , layerId , ( e ) => {
if ( ! measuringRef . current . active ) {
map . getCanvas ( ) . style . cursor = 'pointer'
const feature = e . features ? . [ 0 ]
if ( feature ? . properties ? . name ) {
setHoverHighlight ( map , feature )
hoveredFeatureRef . current = feature
}
}
} )
map . on ( 'mouseleave' , layerId , ( ) => {
if ( ! measuringRef . current . active ) {
map . getCanvas ( ) . style . cursor = ''
setHoverHighlight ( map , null )
hoveredFeatureRef . current = null
}
} )
} )
} )
mapInstance . current = map
// ResizeObserver to handle layout settling, panel changes, window resize
const ro = new ResizeObserver ( ( ) => {
map . resize ( )
} )
ro . observe ( mapRef . current )
return ( ) => {
ro . disconnect ( )
if ( watchIdRef . current != null ) navigator . geolocation . clearWatch ( watchIdRef . current )
if ( gpsMarkerRef . current ) gpsMarkerRef . current . remove ( )
// Clean up measure labels
measureLabelsRef . current . forEach ( el => el . remove ( ) )
measureLabelsRef . current = [ ]
maplibregl . removeProtocol ( 'pmtiles' )
map . remove ( )
}
} , [ setSheetState ] )
/** Create or update the GPS chevron/dot marker */
function createOrUpdateGpsMarker ( map , lat , lon , heading ) {
if ( ! gpsMarkerRef . current ) {
const el = document . createElement ( 'div' )
if ( heading != null && ! isNaN ( heading ) ) {
el . className = 'navi-chevron'
el . innerHTML = CHEVRON _SVG
el . style . transform = ` rotate( ${ heading } deg) `
} else {
el . className = 'navi-gps-dot'
}
gpsMarkerRef . current = new maplibregl . Marker ( { element : el } )
. setLngLat ( [ lon , lat ] )
. addTo ( map )
} else {
gpsMarkerRef . current . setLngLat ( [ lon , lat ] )
const el = gpsMarkerRef . current . getElement ( )
if ( heading != null && ! isNaN ( heading ) ) {
if ( ! el . classList . contains ( 'navi-chevron' ) ) {
el . className = 'navi-chevron'
el . innerHTML = CHEVRON _SVG
}
el . style . transform = ` rotate( ${ heading } deg) `
} else {
if ( ! el . classList . contains ( 'navi-gps-dot' ) ) {
el . className = 'navi-gps-dot'
el . innerHTML = ''
}
}
}
}
// React to permission changes from LocateButton (when user grants after initial denial)
useEffect ( ( ) => {
const map = mapInstance . current
if ( ! map || geoPermission !== 'granted' ) return
// If marker already exists, watchPosition is already running — nothing to do
if ( gpsMarkerRef . current ) return
// Permission was just granted (likely from LocateButton) — create marker + start tracking
const loc = useStore . getState ( ) . userLocation
if ( loc ) {
createOrUpdateGpsMarker ( map , loc . lat , loc . lon , null )
}
if ( ! watchIdRef . current ) {
watchIdRef . current = navigator . geolocation . watchPosition (
( pos ) => {
const { latitude , longitude , heading } = pos . coords
useStore . getState ( ) . setUserLocation ( { lat : latitude , lon : longitude } )
createOrUpdateGpsMarker ( map , latitude , longitude , heading )
} ,
( ) => { } ,
{ enableHighAccuracy : true , maximumAge : 5000 }
)
}
} , [ geoPermission ] )
// Swap map theme when store.theme changes
useEffect ( ( ) => {
const map = mapInstance . current
if ( ! map || currentThemeRef . current === theme ) return
currentThemeRef . current = theme
const center = map . getCenter ( )
const zoom = map . getZoom ( )
const bearing = map . getBearing ( )
const pitch = map . getPitch ( )
map . setStyle ( buildStyle ( theme ) , { diff : false } )
// Re-add sources/layers after style swap
map . once ( 'style.load' , ( ) => {
// Guard against source already existing
if ( ! map . getSource ( ROUTE _SOURCE ) ) {
map . addSource ( ROUTE _SOURCE , {
type : 'geojson' ,
data : { type : 'FeatureCollection' , features : [ ] } ,
} )
}
// Boundary polygon layer
if ( ! map . getLayer ( BOUNDARY _LAYER ) ) {
addBoundaryLayer ( map )
}
// Re-add active overlay layers
if ( activeLayersRef . current . hillshade ) addHillshade ( map )
if ( activeLayersRef . current . traffic ) addTraffic ( map )
if ( activeLayersRef . current . publicLands ) addPublicLands ( map )
if ( activeLayersRef . current . contours ) addContours ( map )
// Re-setup highlight layers
setupHighlightLayers ( map , theme === 'dark' )
// Restore view
map . jumpTo ( { center , zoom , bearing , pitch } )
// Re-render route if exists
const currentRoute = useStore . getState ( ) . route
if ( currentRoute ) updateRoute ( map , currentRoute )
} )
} , [ theme ] )
// Preview pin for selected place
useEffect ( ( ) => {
const map = mapInstance . current
if ( ! map ) return
// Remove old preview marker
if ( previewMarkerRef . current ) {
previewMarkerRef . current . remove ( )
previewMarkerRef . current = null
}
if ( ! selectedPlace ) return
// Only fly to place if it came from search (not map-click which already centered)
if ( selectedPlace . source !== 'map_click' && selectedPlace . source !== 'basemap_label' ) {
map . flyTo ( { center : [ selectedPlace . lon , selectedPlace . lat ] , zoom : 14 , duration : 800 } )
}
// Different visual feedback based on mode
const isFeatureMode = selectedPlace . mode === 'feature'
// Create marker element
const el = document . createElement ( 'div' )
if ( isFeatureMode ) {
// Feature mode: subtle ring indicator
el . className = 'navi-feature-highlight'
} else {
// Reticle mode: pin with center dot
el . className = 'navi-pin-preview'
const dot = document . createElement ( 'div' )
dot . className = 'navi-pin-center-dot'
el . appendChild ( dot )
}
previewMarkerRef . current = new maplibregl . Marker ( { element : el } )
. setLngLat ( [ selectedPlace . lon , selectedPlace . lat ] )
. addTo ( map )
return ( ) => {
if ( previewMarkerRef . current ) {
previewMarkerRef . current . remove ( )
previewMarkerRef . current = null
}
}
} , [ selectedPlace ] )
// Update route polyline when route changes
useEffect ( ( ) => {
const map = mapInstance . current
if ( ! map ) return
if ( ! map . isStyleLoaded ( ) ) {
const handler = ( ) => updateRoute ( map , route )
map . once ( 'idle' , handler )
return ( ) => map . off ( 'idle' , handler )
}
updateRoute ( map , route )
} , [ route ] )
function updateRoute ( map , routeData ) {
if ( ! map ) return
// Remove old route layers
const style = map . getStyle ( )
if ( style ) {
for ( const layer of style . layers ) {
if ( layer . id . startsWith ( ROUTE _LAYER _PREFIX ) ) {
map . removeLayer ( layer . id )
}
}
}
if ( ! routeData || ! routeData . legs ) {
if ( map . getSource ( ROUTE _SOURCE ) ) {
map . getSource ( ROUTE _SOURCE ) . setData ( { type : 'FeatureCollection' , features : [ ] } )
}
return
}
const features = [ ]
for ( let i = 0 ; i < routeData . legs . length ; i ++ ) {
const leg = routeData . legs [ i ]
if ( ! leg . shape ) continue
const coords = decodePolyline ( leg . shape , 6 )
features . push ( {
type : 'Feature' ,
properties : { legIndex : i } ,
geometry : { type : 'LineString' , coordinates : coords } ,
} )
}
const source = map . getSource ( ROUTE _SOURCE )
if ( source ) {
source . setData ( { type : 'FeatureCollection' , features } )
} else {
map . addSource ( ROUTE _SOURCE , {
type : 'geojson' ,
data : { type : 'FeatureCollection' , features } ,
} )
}
// Use CSS variable for route color (read computed value)
const routeColor = getComputedStyle ( document . documentElement ) . getPropertyValue ( '--route-line' ) . trim ( )
for ( let i = 0 ; i < features . length ; i ++ ) {
const layerId = ` ${ ROUTE _LAYER _PREFIX } ${ i } `
if ( ! map . getLayer ( layerId ) ) {
map . addLayer ( {
id : layerId ,
type : 'line' ,
source : ROUTE _SOURCE ,
filter : [ '==' , [ 'get' , 'legIndex' ] , i ] ,
layout : { 'line-join' : 'round' , 'line-cap' : 'round' } ,
paint : {
'line-color' : routeColor || '#7a9a6b' ,
'line-width' : 5 ,
'line-opacity' : 0.85 ,
} ,
} )
}
}
// Fit bounds to route
if ( features . length > 0 ) {
const allCoords = features . flatMap ( ( f ) => f . geometry . coordinates )
const bounds = allCoords . reduce (
( b , c ) => b . extend ( c ) ,
new maplibregl . LngLatBounds ( allCoords [ 0 ] , allCoords [ 0 ] )
)
// Single-panel: no floating detail
const leftPad = 420 // 360px panel + margin
map . fitBounds ( bounds , { padding : { top : 60 , bottom : 60 , left : leftPad , right : 60 } } )
}
}
// Update stop markers when stops change
useEffect ( ( ) => {
const map = mapInstance . current
if ( ! map ) return
// Remove old markers
for ( const m of markersRef . current ) m . remove ( )
markersRef . current = [ ]
if ( popupRef . current ) {
popupRef . current . remove ( )
popupRef . current = null
}
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
const indexOffset = hasGpsOrigin ? 1 : 0
stops . forEach ( ( stop , i ) => {
const displayIndex = i + indexOffset
const effectiveTotal = stops . length + indexOffset
let pinClass = 'navi-pin navi-pin--intermediate'
if ( displayIndex === 0 ) pinClass = 'navi-pin navi-pin--origin'
else if ( displayIndex === effectiveTotal - 1 && effectiveTotal > 1 ) pinClass = 'navi-pin navi-pin--destination'
const label = String . fromCharCode ( 65 + Math . min ( displayIndex , 25 ) )
const el = document . createElement ( 'div' )
el . className = pinClass
el . textContent = label
el . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( )
// Flag so the map-level click handler doesn't fire
pinClickedRef . current = true
if ( popupRef . current ) popupRef . current . remove ( )
const popup = new maplibregl . Popup ( { offset : 20 , closeButton : true } )
. setLngLat ( [ stop . lon , stop . lat ] )
. setHTML (
` <div style="font-size:12px;max-width:200px">
< strong > $ { stop . name } < / strong >
< br / > < button id = "remove-stop-${stop.id}" style = "margin-top:4px;padding:2px 8px;background:var(--status-danger);border:none;border-radius:4px;color:white;cursor:pointer;font-size:11px" > Remove < / button >
< / div > `
)
. addTo ( map )
popup . getElement ( ) . querySelector ( ` #remove-stop- ${ stop . id } ` ) ? . addEventListener ( 'click' , ( ) => {
useStore . getState ( ) . removeStop ( stop . id )
popup . remove ( )
} )
popupRef . current = popup
} )
const marker = new maplibregl . Marker ( { element : el } )
. setLngLat ( [ stop . lon , stop . lat ] )
. addTo ( map )
markersRef . current . push ( marker )
} )
// If stops but no route yet, fit to stops
if ( stops . length > 0 && ! route ) {
if ( stops . length === 1 ) {
map . flyTo ( { center : [ stops [ 0 ] . lon , stops [ 0 ] . lat ] , zoom : 13 } )
} else {
const bounds = stops . reduce (
( b , s ) => b . extend ( [ s . lon , s . lat ] ) ,
new maplibregl . LngLatBounds ( [ stops [ 0 ] . lon , stops [ 0 ] . lat ] , [ stops [ 0 ] . lon , stops [ 0 ] . lat ] )
)
map . fitBounds ( bounds , { padding : { top : 60 , bottom : 60 , left : 420 , right : 60 } } )
}
}
} , [ stops , route , gpsOrigin , geoPermission ] )
// ESC key handler for measurement mode
useEffect ( ( ) => {
const handleKeyDown = ( e ) => {
if ( e . key === "Escape" && measuringRef . current . active ) {
endMeasuring ( )
}
}
window . addEventListener ( "keydown" , handleKeyDown )
return ( ) => window . removeEventListener ( "keydown" , handleKeyDown )
} , [ ] )
// Handle location pick mode for contacts
useEffect ( ( ) => {
const map = mapInstance . current
if ( ! map ) return
if ( pickingLocationFor ) {
map . getCanvas ( ) . style . cursor = 'crosshair'
}
return ( ) => {
if ( map && ! measuringRef . current . active ) {
map . getCanvas ( ) . style . cursor = ''
}
}
} , [ pickingLocationFor ] )
// ESC key handler for location pick mode
useEffect ( ( ) => {
const handleKeyDown = ( e ) => {
if ( e . key === 'Escape' && pickingLocationFor ) {
// Cancel pick mode, reopen modal with original form data
const map = mapInstance . current
if ( map ) map . getCanvas ( ) . style . cursor = ''
setEditingContact ( pickingLocationFor )
clearPickingLocationFor ( )
}
}
window . addEventListener ( 'keydown' , handleKeyDown )
return ( ) => window . removeEventListener ( 'keydown' , handleKeyDown )
} , [ pickingLocationFor , setEditingContact , clearPickingLocationFor ] )
// Track zoom level for indicator
useEffect ( ( ) => {
const map = mapInstance . current
if ( ! map ) return
const updateZoom = ( ) => setZoomLevel ( map . getZoom ( ) )
// Set initial zoom
if ( map . loaded ( ) ) {
updateZoom ( )
} else {
map . once ( "load" , updateZoom )
}
// Subscribe to zoom changes
map . on ( "zoom" , updateZoom )
return ( ) => {
map . off ( "zoom" , updateZoom )
}
} , [ ] )
// Track map center for search viewport bias
useEffect ( ( ) => {
const map = mapInstance . current
if ( ! map ) return
const updateCenter = ( ) => {
const center = map . getCenter ( )
const zoom = map . getZoom ( )
setMapCenter ( { lat : center . lat , lon : center . lng , zoom } )
}
// Set initial center
if ( map . loaded ( ) ) {
updateCenter ( )
} else {
map . once ( "load" , updateCenter )
}
// Update on move end (not every frame)
map . on ( "moveend" , updateCenter )
return ( ) => {
map . off ( "moveend" , updateCenter )
}
} , [ setMapCenter ] )
return (
< div className = "relative w-full h-full" >
< div ref = { mapRef } className = "w-full h-full" { ...contextMenuHandlers } / >
{ /* Zoom level indicator - bottom-left corner */ }
< div
className = "absolute bottom-4 left-4 z-50 px-2 py-1 rounded-full text-xs font-mono pointer-events-none"
style = { {
backgroundColor : "rgba(0, 0, 0, 0.6)" ,
color : "white" ,
fontSize : "12px" ,
padding : "4px 8px" ,
borderRadius : "12px" ,
} }
>
Z { zoomLevel . toFixed ( 1 ) }
< / div >
{ /* Measurement info bar */ }
{ ( measuring . active || measuring . points . length > 1 ) && (
< div
className = "absolute top-4 left-1/2 transform -translate-x-1/2 z-50 flex items-center gap-3 px-4 py-2 rounded-lg"
style = { {
backgroundColor : "rgba(0, 0, 0, 0.8)" ,
color : "white" ,
fontSize : "13px" ,
boxShadow : "0 2px 8px rgba(0,0,0,0.3)" ,
} }
>
< Ruler size = { 16 } style = { { opacity : 0.8 } } / >
< span >
< strong > { formatDistance ( measuring . totalMeters ) } < / strong >
< span style = { { opacity : 0.7 , marginLeft : "6px" } } >
( { measuring . points . length } { measuring . points . length === 1 ? "point" : "points" } )
< / span >
< / span >
{ measuring . active && (
< span style = { { opacity : 0.6 , fontSize : "11px" } } >
Click to add points
< / span >
) }
< button
onClick = { endMeasuring }
className = "px-2 py-1 rounded text-xs font-medium"
style = { {
background : "var(--accent)" ,
color : "white" ,
border : "none" ,
cursor : "pointer" ,
} }
>
Done
< / button >
< button
onClick = { clearMeasuring }
className = "p-1 rounded"
style = { {
background : "transparent" ,
color : "white" ,
border : "none" ,
cursor : "pointer" ,
opacity : 0.7 ,
} }
title = "Clear measurement"
>
< X size = { 16 } / >
< / button >
< / div >
) }
{ /* Radial context menu */ }
< RadialMenu
open = { radialMenu . open }
x = { radialMenu . x }
y = { radialMenu . y }
lat = { radialMenu . lat }
lon = { radialMenu . lon }
wedges = { radialWedges }
centerLabel = { radialMenu . centerLabel }
onDismiss = { ( ) => setRadialMenu ( ( m ) => ( { ... m , open : false } ) ) }
/ >
< / div >
)
} )
export default MapView