2026-03-19 16:12:01 +00:00
< script setup lang = "ts" >
import { ref , onMounted , onUnmounted , watch , computed } from 'vue'
import { useMeshStore } from '@/stores/mesh'
import type { NodePosition } from '@/stores/mesh'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
const mesh = useMeshStore ( )
const mapContainer = ref < HTMLElement | null > ( null )
let map : L . Map | null = null
const markersLayer = ref < L .LayerGroup | null > ( null )
const linesLayer = ref < L .LayerGroup | null > ( null )
// Whether we have any position data to show
const hasPositions = computed ( ( ) => mesh . nodePositions . size > 0 )
// Location sharing state
const sharingLocation = ref ( false )
const locationSource = ref < 'browser' | 'device' > ( 'browser' )
const locationError = ref ( '' )
const hasDeviceGps = computed ( ( ) => mesh . deadmanStatus ? . has _gps ? ? false )
2026-06-11 00:24:40 -04:00
const locationPermissionDenied = computed ( ( ) => locationError . value === 'Location permission denied' )
2026-03-19 16:12:01 +00:00
let geoWatchId : number | null = null
function toggleLocationSharing ( ) {
if ( sharingLocation . value ) {
stopSharing ( )
} else {
startSharing ( )
}
}
function switchSource ( source : 'browser' | 'device' ) {
locationSource . value = source
if ( sharingLocation . value ) {
stopSharing ( )
startSharing ( )
}
}
function startSharing ( ) {
locationError . value = ''
if ( locationSource . value === 'browser' ) {
if ( ! navigator . geolocation ) {
locationError . value = 'Geolocation not supported'
return
}
geoWatchId = navigator . geolocation . watchPosition (
( pos ) => {
mesh . updateSelfPosition ( pos . coords . latitude , pos . coords . longitude , 'This Node' )
sharingLocation . value = true
locationError . value = ''
} ,
( err ) => {
locationError . value = err . code === 1 ? 'Location permission denied' : err . message
sharingLocation . value = false
} ,
{ enableHighAccuracy : true , timeout : 15000 , maximumAge : 30000 } ,
)
sharingLocation . value = true
} else {
// Device GPS — position data comes from deadman/mesh GPS module
sharingLocation . value = true
}
}
function stopSharing ( ) {
if ( geoWatchId !== null ) {
navigator . geolocation . clearWatch ( geoWatchId )
geoWatchId = null
}
sharingLocation . value = false
mesh . nodePositions . delete ( - 1 )
}
function createMarkerIcon ( type : 'self' | 'online' | 'offline' ) : L . DivIcon {
const colorMap = {
self : { bg : '#fb923c' , border : '#f59e0b' , shadow : 'rgba(251,146,60,0.5)' } ,
online : { bg : '#4ade80' , border : '#22c55e' , shadow : 'rgba(74,222,128,0.4)' } ,
offline : { bg : '#6b7280' , border : '#4b5563' , shadow : 'rgba(107,114,128,0.3)' } ,
}
const c = colorMap [ type ]
const size = type === 'self' ? 16 : 12
const pulse = type === 'self'
? ` <div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width: ${ size + 12 } px;height: ${ size + 12 } px;border-radius:50%;background: ${ c . shadow } ;animation:mesh-map-pulse 2s infinite;"></div> `
: ''
return L . divIcon ( {
className : 'mesh-map-marker-wrapper' ,
iconSize : [ size + 12 , size + 12 ] ,
iconAnchor : [ ( size + 12 ) / 2 , ( size + 12 ) / 2 ] ,
popupAnchor : [ 0 , - ( size / 2 + 6 ) ] ,
html : `
$ { pulse }
< div style = "
width : $ { size } px ; height : $ { size } px ;
border - radius : 50 % ;
background : $ { c . bg } ;
border : 2 px solid $ { c . border } ;
box - shadow : 0 0 8 px $ { c . shadow } ;
position : absolute ; top : 50 % ; left : 50 % ;
transform : translate ( - 50 % , - 50 % ) ;
z - index : 2 ;
" > < / div >
` ,
} )
}
function getSignalBars ( rssi : number | null ) : string {
if ( rssi === null ) return 'Unknown'
if ( rssi >= - 70 ) return 'Strong'
if ( rssi >= - 90 ) return 'Good'
if ( rssi >= - 110 ) return 'Weak'
return 'Very Weak'
}
function formatLastHeard ( timestamp : string ) : string {
const now = Date . now ( )
const then = new Date ( timestamp ) . getTime ( )
const diffSecs = Math . floor ( ( now - then ) / 1000 )
if ( diffSecs < 60 ) return 'Just now'
if ( diffSecs < 3600 ) return ` ${ Math . floor ( diffSecs / 60 ) } m ago `
if ( diffSecs < 86400 ) return ` ${ Math . floor ( diffSecs / 3600 ) } h ago `
return ` ${ Math . floor ( diffSecs / 86400 ) } d ago `
}
function truncatePubkey ( pubkey : string | null ) : string {
if ( ! pubkey ) return 'No pubkey'
if ( pubkey . length <= 16 ) return pubkey
return ` ${ pubkey . slice ( 0 , 8 ) } ... ${ pubkey . slice ( - 8 ) } `
}
function buildPopupContent (
name : string ,
pubkey : string | null ,
rssi : number | null ,
lastHeard : string ,
hops : number ,
isSelf : boolean ,
) : string {
const signal = getSignalBars ( rssi )
const heard = formatLastHeard ( lastHeard )
const truncPk = truncatePubkey ( pubkey )
const selfBadge = isSelf
? '<span style="display:inline-block;background:rgba(251,146,60,0.2);color:#fb923c;font-size:0.65rem;padding:1px 6px;border-radius:4px;margin-left:6px;font-weight:600;">THIS NODE</span>'
: ''
return `
< div style = "font-family:'Avenir Next',sans-serif;min-width:160px;" >
< div style = "font-weight:600;font-size:0.9rem;color:#fff;margin-bottom:4px;" >
$ { name } $ { selfBadge }
< / div >
< div style = "font-size:0.72rem;color:rgba(255,255,255,0.5);font-family:monospace;margin-bottom:8px;word-break:break-all;" >
$ { truncPk }
< / div >
< div style = "display:flex;flex-direction:column;gap:3px;font-size:0.78rem;" >
$ { ! isSelf ? ` <div style="color:rgba(255,255,255,0.7);">Signal: <span style="color: ${ rssi !== null && rssi >= - 90 ? '#4ade80' : '#fbbf24' } ;"> ${ signal } </span> ${ rssi !== null ? ` ( ${ rssi } dBm) ` : '' } </div> ` : '' }
$ { ! isSelf ? ` <div style="color:rgba(255,255,255,0.7);">Hops: <span style="color:rgba(255,255,255,0.9);"> ${ hops } </span></div> ` : '' }
< div style = "color:rgba(255,255,255,0.7);" > Last heard : < span style = "color:rgba(255,255,255,0.9);" > $ { heard } < / span > < / div >
< / div >
< / div >
`
}
function initMap ( ) {
if ( ! mapContainer . value || map ) return
const el = mapContainer . value
const rect = el . getBoundingClientRect ( )
// If container has no height yet, retry
if ( rect . height < 10 ) {
setTimeout ( initMap , 150 )
return
}
map = L . map ( el , {
zoomControl : true ,
attributionControl : true ,
center : [ 30 , 0 ] ,
zoom : 3 ,
} )
L . tileLayer ( 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' , {
attribution : '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/">CARTO</a>' ,
subdomains : 'abcd' ,
maxZoom : 19 ,
detectRetina : true ,
} ) . addTo ( map )
markersLayer . value = L . layerGroup ( ) . addTo ( map )
linesLayer . value = L . layerGroup ( ) . addTo ( map )
// Give Leaflet a frame to measure, then invalidate
requestAnimationFrame ( ( ) => {
if ( map ) {
try { map . invalidateSize ( ) } catch { /* destroyed */ }
}
} )
// Style attribution for dark theme
const attrib = el . querySelector ( '.leaflet-control-attribution' )
if ( attrib instanceof HTMLElement ) {
attrib . style . background = 'rgba(0,0,0,0.6)'
attrib . style . color = 'rgba(255,255,255,0.4)'
attrib . style . fontSize = '0.65rem'
}
updateMarkers ( )
}
function updateMarkers ( ) {
if ( ! map || ! markersLayer . value || ! linesLayer . value ) return
markersLayer . value . clearLayers ( )
linesLayer . value . clearLayers ( )
const positions = mesh . nodePositions
if ( positions . size === 0 ) return
const bounds : L . LatLngExpression [ ] = [ ]
const selfPos = positions . get ( - 1 )
// Find which contact_ids are in the peers list (for online status)
const peerMap = new Map ( mesh . peers . map ( p => [ p . contact _id , p ] ) )
positions . forEach ( ( pos : NodePosition , contactId : number ) => {
const isSelf = contactId === - 1
const peer = peerMap . get ( contactId )
const isOnline = isSelf || ! ! peer
const marker = L . marker ( [ pos . lat , pos . lng ] , {
icon : createMarkerIcon ( isSelf ? 'self' : isOnline ? 'online' : 'offline' ) ,
} )
const name = isSelf
? ( mesh . status ? . self _advert _name ? ? 'This Node' )
: ( peer ? . advert _name ? ? pos . label ? ? ` Node ${ contactId } ` )
const pubkey = isSelf ? null : ( peer ? . pubkey _hex ? ? null )
const rssi = peer ? . rssi ? ? null
const lastHeard = isSelf ? new Date ( ) . toISOString ( ) : ( peer ? . last _heard ? ? pos . timestamp )
const hops = peer ? . hops ? ? 0
marker . bindPopup ( buildPopupContent ( name , pubkey , rssi , lastHeard , hops , isSelf ) , {
className : 'mesh-map-popup' ,
closeButton : true ,
maxWidth : 250 ,
} )
markersLayer . value ! . addLayer ( marker )
bounds . push ( [ pos . lat , pos . lng ] )
// Draw dashed line from self to each connected peer
if ( ! isSelf && selfPos ) {
const line = L . polyline (
[ [ selfPos . lat , selfPos . lng ] , [ pos . lat , pos . lng ] ] ,
{
color : isOnline ? 'rgba(74,222,128,0.4)' : 'rgba(107,114,128,0.3)' ,
weight : 1.5 ,
dashArray : '6, 8' ,
opacity : 0.7 ,
} ,
)
linesLayer . value ! . addLayer ( line )
}
} )
// Fit map to show all markers
if ( bounds . length > 1 ) {
map . fitBounds ( L . latLngBounds ( bounds ) , { padding : [ 40 , 40 ] , maxZoom : 14 } )
} else if ( bounds . length === 1 && bounds [ 0 ] ) {
map . setView ( bounds [ 0 ] , 12 )
}
}
function handleResize ( ) {
if ( map ) {
map . invalidateSize ( )
}
}
// Watch for changes in node positions and peers
watch (
( ) => [ mesh . nodePositions . size , mesh . peers . length ] ,
( ) => {
updateMarkers ( )
} ,
)
let resizeObserver : ResizeObserver | null = null
onMounted ( ( ) => {
window . addEventListener ( 'resize' , handleResize )
if ( mapContainer . value ) {
resizeObserver = new ResizeObserver ( ( entries ) => {
const entry = entries [ 0 ]
if ( ! entry ) return
const { height } = entry . contentRect
if ( ! map && height > 10 ) {
initMap ( )
} else if ( map ) {
try { map . invalidateSize ( ) } catch { /* destroyed */ }
}
} )
resizeObserver . observe ( mapContainer . value )
}
// Fallback init
setTimeout ( initMap , 300 )
} )
onUnmounted ( ( ) => {
window . removeEventListener ( 'resize' , handleResize )
stopSharing ( )
if ( resizeObserver ) {
resizeObserver . disconnect ( )
resizeObserver = null
}
if ( map ) {
map . remove ( )
map = null
}
markersLayer . value = null
linesLayer . value = null
} )
< / script >
< template >
< div class = "mesh-map-outer" >
< div ref = "mapContainer" class = "mesh-map-inner" > < / div >
<!-- Floating hint when no positions -- >
< div v-if = "!hasPositions && !sharingLocation" class="mesh-map-hint" >
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke -width = " 2 " stroke -linecap = " round " stroke -linejoin = " round " >
< path d = "M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" / >
< circle cx = "12" cy = "10" r = "3" / >
< / svg >
2026-06-11 00:24:40 -04:00
< span > { { locationPermissionDenied ? 'Local location is off. Other device positions will appear when received.' : 'Waiting for mesh device positions.' } } < / span >
2026-03-19 16:12:01 +00:00
< / div >
<!-- Location sharing overlay -- >
< div class = "mesh-map-location-bar" >
< div class = "mesh-map-location-row" >
< svg class = "mesh-map-location-icon" width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke -width = " 2 " stroke -linecap = " round " stroke -linejoin = " round " >
< path d = "M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" / >
< circle cx = "12" cy = "10" r = "3" / >
< / svg >
< span class = "mesh-map-location-label" > Share Location < / span >
< button
class = "mesh-map-toggle"
: class = "{ active: sharingLocation }"
role = "switch"
: aria - checked = "sharingLocation"
@ click = "toggleLocationSharing"
>
< span class = "mesh-map-toggle-knob" / >
< / button >
< / div >
<!-- Source selector ( visible when sharing and device GPS available ) -- >
< div v-if = "sharingLocation && hasDeviceGps" class="mesh-map-source-row" >
< button
class = "mesh-map-source-btn"
: class = "{ active: locationSource === 'browser' }"
@ click = "switchSource('browser')"
> This Machine < / button >
< button
class = "mesh-map-source-btn"
: class = "{ active: locationSource === 'device' }"
@ click = "switchSource('device')"
> Mesh Radio GPS < / button >
< / div >
2026-06-11 00:24:40 -04:00
< div v-if = "locationError" class="mesh-map-location-error" >
{ { locationPermissionDenied ? 'Location permission denied. Peer locations can still appear on the map.' : locationError } }
< / div >
2026-03-19 16:12:01 +00:00
< / div >
< / div >
< / template >
< style >
/* Must be unscoped — Leaflet creates DOM nodes dynamically */
/* CRITICAL: Override Tailwind's img { max-width: 100% } which breaks Leaflet tiles */
. mesh - map - inner img {
max - width : none ! important ;
max - height : none ! important ;
}
. mesh - map - outer {
width : 100 % ;
height : 100 % ;
min - height : 420 px ;
position : relative ;
overflow : hidden ;
}
. mesh - map - inner {
position : absolute ;
top : 0 ;
left : 0 ;
right : 0 ;
bottom : 0 ;
background : # 1 a1a2e ;
}
/* Leaflet adds .leaflet-container to the element itself (not a child) */
. mesh - map - inner . leaflet - container {
background : # 1 a1a2e ;
}
. mesh - map - hint {
position : absolute ;
bottom : 64 px ;
left : 50 % ;
transform : translateX ( - 50 % ) ;
z - index : 400 ;
display : flex ;
align - items : center ;
gap : 8 px ;
padding : 8 px 16 px ;
background : rgba ( 0 , 0 , 0 , 0.7 ) ;
backdrop - filter : blur ( 12 px ) ;
border : 1 px solid rgba ( 255 , 255 , 255 , 0.1 ) ;
border - radius : 8 px ;
color : rgba ( 255 , 255 , 255 , 0.6 ) ;
font - size : 0.78 rem ;
white - space : nowrap ;
pointer - events : none ;
}
/* ─── Location sharing overlay ─── */
. mesh - map - location - bar {
position : absolute ;
bottom : 8 px ;
left : 8 px ;
right : 8 px ;
z - index : 500 ;
background : rgba ( 0 , 0 , 0 , 0.75 ) ;
backdrop - filter : blur ( 16 px ) ;
border : 1 px solid rgba ( 255 , 255 , 255 , 0.1 ) ;
border - radius : 10 px ;
padding : 8 px 12 px ;
display : flex ;
flex - direction : column ;
gap : 6 px ;
}
. mesh - map - location - row {
display : flex ;
align - items : center ;
gap : 8 px ;
}
. mesh - map - location - icon {
color : rgba ( 255 , 255 , 255 , 0.5 ) ;
flex - shrink : 0 ;
}
. mesh - map - location - label {
font - size : 0.78 rem ;
color : rgba ( 255 , 255 , 255 , 0.7 ) ;
font - weight : 500 ;
flex : 1 ;
}
/* Toggle switch */
. mesh - map - toggle {
width : 36 px ;
height : 20 px ;
border - radius : 10 px ;
border : 1 px solid rgba ( 255 , 255 , 255 , 0.15 ) ;
background : rgba ( 255 , 255 , 255 , 0.1 ) ;
cursor : pointer ;
position : relative ;
padding : 0 ;
flex - shrink : 0 ;
transition : all 0.25 s ease ;
}
. mesh - map - toggle . active {
background : rgba ( 251 , 146 , 60 , 0.35 ) ;
border - color : rgba ( 251 , 146 , 60 , 0.5 ) ;
}
. mesh - map - toggle - knob {
display : block ;
width : 14 px ;
height : 14 px ;
border - radius : 50 % ;
background : rgba ( 255 , 255 , 255 , 0.6 ) ;
position : absolute ;
top : 2 px ;
left : 2 px ;
transition : all 0.25 s ease ;
}
. mesh - map - toggle . active . mesh - map - toggle - knob {
left : 18 px ;
background : # fb923c ;
}
/* Source selector */
. mesh - map - source - row {
display : flex ;
gap : 4 px ;
background : rgba ( 0 , 0 , 0 , 0.3 ) ;
border - radius : 6 px ;
padding : 2 px ;
}
. mesh - map - source - btn {
flex : 1 ;
padding : 4 px 8 px ;
font - size : 0.7 rem ;
font - weight : 500 ;
color : rgba ( 255 , 255 , 255 , 0.5 ) ;
background : transparent ;
border : none ;
border - radius : 4 px ;
cursor : pointer ;
transition : all 0.2 s ease ;
}
. mesh - map - source - btn : hover {
color : rgba ( 255 , 255 , 255 , 0.7 ) ;
}
. mesh - map - source - btn . active {
color : # fff ;
background : rgba ( 255 , 255 , 255 , 0.1 ) ;
}
. mesh - map - location - error {
font - size : 0.7 rem ;
color : # ef4444 ;
}
< / style >
< style >
/* Global styles for Leaflet popup theming - must not be scoped */
. mesh - map - popup . leaflet - popup - content - wrapper {
background : rgba ( 0 , 0 , 0 , 0.85 ) ;
backdrop - filter : blur ( 16 px ) ;
border : 1 px solid rgba ( 255 , 255 , 255 , 0.15 ) ;
border - radius : 10 px ;
box - shadow : 0 8 px 32 px rgba ( 0 , 0 , 0 , 0.5 ) ;
color : # fff ;
}
. mesh - map - popup . leaflet - popup - tip {
background : rgba ( 0 , 0 , 0 , 0.85 ) ;
border : 1 px solid rgba ( 255 , 255 , 255 , 0.15 ) ;
}
. mesh - map - popup . leaflet - popup - close - button {
color : rgba ( 255 , 255 , 255 , 0.5 ) ;
font - size : 18 px ;
padding : 4 px 8 px ;
}
. mesh - map - popup . leaflet - popup - close - button : hover {
color : rgba ( 255 , 255 , 255 , 0.9 ) ;
}
/* Marker wrapper reset */
. mesh - map - marker - wrapper {
background : none ! important ;
border : none ! important ;
}
/* Pulse animation for self marker */
@ keyframes mesh - map - pulse {
0 % { transform : translate ( - 50 % , - 50 % ) scale ( 1 ) ; opacity : 0.6 ; }
50 % { transform : translate ( - 50 % , - 50 % ) scale ( 1.8 ) ; opacity : 0 ; }
100 % { transform : translate ( - 50 % , - 50 % ) scale ( 1 ) ; opacity : 0 ; }
}
/* Dark theme for Leaflet zoom controls */
. mesh - map - inner . leaflet - control - zoom a {
background : rgba ( 0 , 0 , 0 , 0.7 ) ! important ;
color : rgba ( 255 , 255 , 255 , 0.8 ) ! important ;
border - color : rgba ( 255 , 255 , 255 , 0.1 ) ! important ;
}
. mesh - map - inner . leaflet - control - zoom a : hover {
background : rgba ( 0 , 0 , 0 , 0.85 ) ! important ;
color : # fff ! important ;
}
< / style >