mirror of
				https://github.com/brianshea2/meshmap.net.git
				synced 2025-03-05 21:00:01 -08:00 
			
		
		
		
	
		
			
				
	
	
		
			364 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			HTML
		
	
	
	
	
	
			
		
		
	
	
			364 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			HTML
		
	
	
	
	
	
| <!DOCTYPE html>
 | |
| <html lang="en">
 | |
| <meta charset="utf-8">
 | |
| <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
 | |
| <meta name="description" content="A nearly live map of Meshtastic nodes seen by the official Meshtastic MQTT server">
 | |
| <title>MeshMap - Meshtastic Node Map</title>
 | |
| <link rel="preload" href="/nodes.json" as="fetch" crossorigin>
 | |
| <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900">
 | |
| <link rel="stylesheet" href="https://unpkg.com/font-awesome@4.7.0/css/font-awesome.min.css">
 | |
| <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
 | |
| <link rel="stylesheet" href="https://unpkg.com/leaflet-easybutton@2.4.0/src/easy-button.css">
 | |
| <link rel="stylesheet" href="https://unpkg.com/leaflet-search@4.0.0/dist/leaflet-search.min.css">
 | |
| <link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css">
 | |
| <link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css">
 | |
| <link rel="manifest" href="/site.webmanifest">
 | |
| <style>
 | |
|   html, body, #map {
 | |
|     height: 100%;
 | |
|     width: 100vw;
 | |
|   }
 | |
|   body {
 | |
|     margin: 0;
 | |
|     padding: 0;
 | |
|   }
 | |
|   body.dark {
 | |
|     filter: invert(1) hue-rotate(180deg) brightness(1.25);
 | |
|   }
 | |
|   body.dark .dark-hidden {
 | |
|     display: none;
 | |
|   }
 | |
|   body:not(.dark) .dark-only {
 | |
|     display: none;
 | |
|   }
 | |
|   #header {
 | |
|     background-color: #fff;
 | |
|     box-shadow: 0 0 4px 0 rgb(0 0 0 / 40%);
 | |
|     color: #333;
 | |
|     display: flex;
 | |
|     gap: 2ch;
 | |
|     font-family: "Inter", sans-serif;
 | |
|     padding: 0.75em;
 | |
|     position: absolute;
 | |
|     top: 0;
 | |
|     left: 0;
 | |
|     right: 0;
 | |
|     z-index: 1000;
 | |
|   }
 | |
|   #header a {
 | |
|     color: inherit;
 | |
|     text-decoration: none;
 | |
|   }
 | |
|   #header a:hover {
 | |
|     opacity: 0.7;
 | |
|   }
 | |
|   #header div:nth-child(2) {
 | |
|     flex-grow: 1;
 | |
|     text-align: right;
 | |
|   }
 | |
|   table {
 | |
|     border-collapse: collapse;
 | |
|   }
 | |
|   table :is(th, td) {
 | |
|     text-align: left;
 | |
|     vertical-align: top;
 | |
|   }
 | |
|   table :is(th, td):nth-child(n+2) {
 | |
|     padding-left: 1em;
 | |
|   }
 | |
|   .break {
 | |
|     word-break: break-all;
 | |
|   }
 | |
|   .leaflet-top {
 | |
|     top: 4em;
 | |
|   }
 | |
|   .leaflet-tooltip, .leaflet-popup-content {
 | |
|     font-family: "Inter", sans-serif;
 | |
|     font-size: 12px;
 | |
|   }
 | |
|   .leaflet-popup-content .title {
 | |
|     font-size: 13px;
 | |
|     font-weight: bold;
 | |
|     margin-bottom: 3px;
 | |
|   }
 | |
|   .leaflet-popup-content table {
 | |
|     margin-top: 1em;
 | |
|   }
 | |
|   body.dark .leaflet-shadow-pane {
 | |
|     display: none;
 | |
|   }
 | |
|   body.dark :is(.leaflet-tooltip, .leaflet-popup-content-wrapper, .leaflet-popup-tip) {
 | |
|     box-shadow: 0 0 4px 0 rgb(0 0 0 / 40%);
 | |
|   }
 | |
|   @media (hover: none) {
 | |
|     .leaflet-tooltip-pane {
 | |
|       display: none;
 | |
|     }
 | |
|   }
 | |
| </style>
 | |
| <div id="header">
 | |
|   <div><a href="https://meshmap.net/" title="A nearly live map of Meshtastic nodes seen by the official Meshtastic MQTT server">MeshMap</a></div>
 | |
|   <div>
 | |
|     <a
 | |
|       href="#"
 | |
|       onclick="window.localStorage.setItem('theme', document.body.classList.toggle('dark') ? 'dark' : 'light');return false"
 | |
|       title="Toggle dark mode"
 | |
|     ><i class="fa fa-moon-o fa-lg dark-hidden"></i><i class="fa fa-sun-o fa-lg dark-only"></i></a>
 | |
|   </div>
 | |
|   <div><a href="https://github.com/brianshea2/meshmap.net" title="GitHub"><i class="fa fa-github fa-lg"></i></a></div>
 | |
|   <div><a href="https://meshtastic.org/">Meshtastic</a></div>
 | |
| </div>
 | |
| <div id="map"></div>
 | |
| <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
 | |
| <script src="https://unpkg.com/leaflet-easybutton@2.4.0/src/easy-button.js"></script>
 | |
| <script src="https://unpkg.com/leaflet-search@4.0.0/dist/leaflet-search.min.js"></script>
 | |
| <script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
 | |
| <script>
 | |
|   const ipinfoToken = 'aeb066758afd49'
 | |
|   const updateInterval = 65000
 | |
|   const zoomLevelNode = 10
 | |
|   const markersByNode = {}
 | |
|   const neighborsByNode = {}
 | |
|   const nodesBySearchString = {}
 | |
|   const precisionMargins = [
 | |
|     11939464, 5969732, 2984866, 1492433, 746217, 373108, 186554, 93277,
 | |
|     46639, 23319, 11660, 5830, 2915, 1457, 729, 364,
 | |
|     182, 91, 46, 23, 11, 6, 3, 1,
 | |
|     1, 0, 0, 0, 0, 0, 0, 0
 | |
|   ]
 | |
|   // encodes html reserved characters
 | |
|   const html = str => str?.replace(/["&<>]/g, match => `&#${match.charCodeAt(0)};`)
 | |
|   // makes more human-readable time duration strings
 | |
|   const duration = d => {
 | |
|     let s = ''
 | |
|     if (d > 86400) {
 | |
|       s += `${Math.floor(d / 86400)}d `
 | |
|       d %= 86400
 | |
|     }
 | |
|     if (d > 3600) {
 | |
|       s += `${Math.floor(d / 3600)}h `
 | |
|       d %= 3600
 | |
|     }
 | |
|     s += `${Math.floor(d / 60)}min`
 | |
|     return s
 | |
|   }
 | |
|   const since = t => `${duration(Date.now() / 1000 - t)} ago`
 | |
|   // set theme
 | |
|   if (window.localStorage.getItem('theme') === 'dark') {
 | |
|     document.body.classList.add('dark')
 | |
|   }
 | |
|   // init map
 | |
|   const map = L.map('map', {
 | |
|     center: window.localStorage.getItem('center')?.split(',') ?? [25, 0],
 | |
|     zoom: window.localStorage.getItem('zoom') ?? 2,
 | |
|     zoomControl: false,
 | |
|     worldCopyJump: true,
 | |
|   })
 | |
|   // add tiles
 | |
|   L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
 | |
|     attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
 | |
|     maxZoom: 19,
 | |
|   }).addTo(map)
 | |
|   // add marker group
 | |
|   const markers = L.markerClusterGroup({
 | |
|     disableClusteringAtZoom: zoomLevelNode,
 | |
|     spiderfyOnMaxZoom: false,
 | |
|   }).addTo(map)
 | |
|   // add node details layer (neighbor lines, precision circle)
 | |
|   const detailsLayer = L.layerGroup().addTo(map)
 | |
|   map.on('click', () => detailsLayer.clearLayers())
 | |
|   // add search control
 | |
|   map.addControl(new L.Control.Search({
 | |
|     layer: markers,
 | |
|     propertyName: 'searchString',
 | |
|     initial: false,
 | |
|     position: 'topleft',
 | |
|     marker: false,
 | |
|     moveToLocation: (_, s) => showNode(nodesBySearchString[s]),
 | |
|   }))
 | |
|   // add zoom control
 | |
|   L.control.zoom({position: 'topright'}).addTo(map)
 | |
|   // add geolocation control
 | |
|   L.easyButton({
 | |
|     position: 'topright',
 | |
|     states: [
 | |
|       {
 | |
|         stateName: 'geolocation-button',
 | |
|         title: 'Center map to current IP geolocation',
 | |
|         icon: 'fa-crosshairs fa-lg',
 | |
|         onClick: () => {
 | |
|           fetch(`https://ipinfo.io/json?token=${ipinfoToken}`)
 | |
|             .then(r => r.json())
 | |
|             .then(({loc}) => loc && map.flyTo(loc.split(','), zoomLevelNode))
 | |
|             .catch(e => console.error('Failed to set location:', e))
 | |
|         },
 | |
|       },
 | |
|     ],
 | |
|   }).addTo(map)
 | |
|   // track and store map position
 | |
|   map.on('moveend', () => {
 | |
|     const center = map.getCenter()
 | |
|     window.localStorage.setItem('center', [center.lat, center.lng].join(','))
 | |
|   })
 | |
|   map.on('zoomend', () => {
 | |
|     window.localStorage.setItem('zoom', map.getZoom())
 | |
|   })
 | |
|   // generates html for a node link
 | |
|   const nodeLink = (num, label) => `<a href="#${num}" onclick="showNode(${num});return false">${html(label)}</a>`
 | |
|   // updates node map markers
 | |
|   const updateNodes = data => Object.entries(data).forEach(([nodeNum, node]) => {
 | |
|     const {
 | |
|       longName, shortName, hwModel, role,
 | |
|       fwVersion, region, modemPreset, hasDefaultCh, onlineLocalNodes,
 | |
|       latitude, longitude, altitude, precision,
 | |
|       batteryLevel, voltage, chUtil, airUtilTx, uptime,
 | |
|       temperature, relativeHumidity, barometricPressure,
 | |
|       neighbors, seenBy
 | |
|     } = node
 | |
|     const id = `!${Number(nodeNum).toString(16)}`
 | |
|     neighborsByNode[nodeNum] ??= new Set()
 | |
|     if (neighbors) {
 | |
|       Object.keys(neighbors).forEach(neighborNum => {
 | |
|         neighborsByNode[neighborNum] ??= new Set()
 | |
|         neighborsByNode[neighborNum].add(nodeNum)
 | |
|         neighborsByNode[nodeNum].add(neighborNum)
 | |
|       })
 | |
|     }
 | |
|     const position = L.latLng([latitude, longitude].map(x => x / 10000000))
 | |
|     const lastSeen = Math.max(...Object.values(seenBy))
 | |
|     const opacity = 1.0 - (Date.now() / 1000 - lastSeen) / 129600
 | |
|     const tooltipContent = `${html(longName)} (${html(shortName)}) ${since(lastSeen)}`
 | |
|     const popupContent = `
 | |
|       <div class="title">${html(longName)} (${html(shortName)})</div>
 | |
|       <div>${nodeLink(nodeNum, id)} | ${html(role)} | ${html(hwModel)}</div>
 | |
|       <table><tbody>
 | |
|       ${fwVersion ? `<tr><th>Firmware</th><td>${html(fwVersion)}</td></tr>` : ''}
 | |
|       ${region ? `<tr><th>Region</th><td>${html(region)}</td></tr>` : ''}
 | |
|       ${modemPreset ? `<tr><th>Modem preset</th><td>${html(modemPreset)}</td></tr>` : ''}
 | |
|       ${hasDefaultCh ? `<tr><th>Has default channel</th><td>True</td></tr>` : ''}
 | |
|       ${onlineLocalNodes ? `<tr><th>Online local nodes</th><td>${onlineLocalNodes}</td></tr>` : ''}
 | |
|       ${batteryLevel ? `<tr><th>Power</th><td>${batteryLevel > 100 ? 'Plugged in' : `${batteryLevel}%`}${voltage ? ` (${voltage.toFixed(2)}V)` : ''}</td></tr>` : ''}
 | |
|       ${chUtil ? `<tr><th>ChUtil</th><td>${chUtil.toFixed(2)}%</td></tr>` : ''}
 | |
|       ${airUtilTx ? `<tr><th>AirUtilTX</th><td>${airUtilTx.toFixed(2)}%</td></tr>` : ''}
 | |
|       ${uptime ? `<tr><th>Uptime</th><td>${duration(uptime)}</td></tr>` : ''}
 | |
|       ${temperature ? `<tr><th>Temperature</th><td>${temperature.toFixed(1)}℃ / ${(temperature * 1.8 + 32).toFixed(1)}℉</td></tr>` : ''}
 | |
|       ${relativeHumidity ? `<tr><th>Relative Humidity</th><td>${Math.round(relativeHumidity)}%</td></tr>` : ''}
 | |
|       ${barometricPressure ? `<tr><th>Barometric Pressure</th><td>${Math.round(barometricPressure)} hPa</td></tr>` : ''}
 | |
|       ${altitude ? `<tr><th>Altitude</th><td>${altitude.toLocaleString()} m above MSL</td></tr>` : ''}
 | |
|       ${precision && precisionMargins[precision-1] ?
 | |
|         `<tr><th>Location precision</th><td>± ${precisionMargins[precision-1].toLocaleString()} m (orange circle)</td></tr>` : ''
 | |
|       }
 | |
|       </tbody></table>
 | |
|       <table><thead>
 | |
|       <tr><th>Last seen</th><th>via</th><th>root topic</th><th>channel</th></tr>
 | |
|       </thead><tbody>
 | |
|       ${Array.from(
 | |
|         new Map(
 | |
|           Object.entries(seenBy)
 | |
|             .map(([topic, seen]) => (m => ({seen, via: m[3] ?? id, root: m[1], chan: m[2]}))(topic.match(/^(.*)(?:\/2\/e\/(.*)\/(![0-9a-f]+)|\/2\/map\/)$/)))
 | |
|             .sort((a, b) => a.seen - b.seen)
 | |
|             .map(v => [v.via, v])
 | |
|         ).values(),
 | |
|         ({seen, via, root, chan}) => `
 | |
|           <tr>
 | |
|           <td>${since(seen)}</td>
 | |
|           <td>${via !== id ? (num => data[num] ? nodeLink(num, via) : via)(parseInt(via.slice(1), 16)) : 'self'}</td>
 | |
|           <td class="break">${html(root)}</td>
 | |
|           <td class="break">${html(chan ?? 'n/a (MapReport)')}</td>
 | |
|           </tr>
 | |
|         `
 | |
|       ).reverse().join('')}
 | |
|       </tbody></table>
 | |
|     `
 | |
|     const populateDetailsLayer = () => {
 | |
|       detailsLayer.clearLayers()
 | |
|       if (precision && precisionMargins[precision-1]) {
 | |
|         L.circle(position, {radius: precisionMargins[precision-1], color: '#ffa932'})
 | |
|           .addTo(detailsLayer)
 | |
|       }
 | |
|       neighborsByNode[nodeNum].forEach(neighborNum => {
 | |
|         if (markersByNode[neighborNum] === undefined) {
 | |
|           return
 | |
|         }
 | |
|         const neighborId = `!${Number(neighborNum).toString(16)}`
 | |
|         const neighborContent = `
 | |
|           <table><tbody>
 | |
|           <tr><th>Neighbor</th><td>${html(id)} <-> ${html(neighborId)}</td></tr>
 | |
|           <tr><th>Distance</th><td>${Math.round(map.distance(position, markersByNode[neighborNum].getLatLng())).toLocaleString()} m</td></tr>
 | |
|           ${neighbors?.[neighborNum]?.snr ? `<tr><th>SNR</th><td>${neighbors[neighborNum].snr} dB</td></tr>` : ''}
 | |
|           ${neighbors?.[neighborNum]?.updated ? `<tr><th>Last seen</th><td>${since(neighbors[neighborNum].updated)}</td></tr>` : ''}
 | |
|           </tbody></table>
 | |
|         `
 | |
|         L.polyline([position, markersByNode[neighborNum].getLatLng()], {weight: 4})
 | |
|           .bindTooltip(neighborContent, {sticky: true})
 | |
|           .on('click', () => showNode(neighborNum))
 | |
|           .addTo(detailsLayer)
 | |
|       })
 | |
|     }
 | |
|     if (markersByNode[nodeNum] === undefined) {
 | |
|       const searchString = `${longName} (${shortName}) ${id}`
 | |
|       nodesBySearchString[searchString] = nodeNum
 | |
|       markersByNode[nodeNum] = L.marker(position, {alt: 'Node', opacity, searchString})
 | |
|         .bindTooltip(tooltipContent)
 | |
|         .bindPopup(popupContent, {maxWidth: 500})
 | |
|         .on('popupopen', () => {
 | |
|           history.replaceState(null, '', `#${nodeNum}`)
 | |
|           populateDetailsLayer()
 | |
|         })
 | |
|         .addTo(markers)
 | |
|     } else {
 | |
|       markersByNode[nodeNum].setOpacity(opacity)
 | |
|       markersByNode[nodeNum].setTooltipContent(tooltipContent)
 | |
|       markersByNode[nodeNum].setPopupContent(popupContent)
 | |
|       markersByNode[nodeNum].setLatLng(position)
 | |
|       if (markersByNode[nodeNum].isPopupOpen()) {
 | |
|         populateDetailsLayer()
 | |
|       }
 | |
|     }
 | |
|   })
 | |
|   // fetches node data, updates map, repeats
 | |
|   const drawMap = async () => {
 | |
|     try {
 | |
|       await fetch('/nodes.json').then(r => r.json()).then(updateNodes)
 | |
|     } catch (e) {
 | |
|       console.error('Failed to update nodes:', e)
 | |
|     }
 | |
|     setTimeout(() => {
 | |
|       if (document.hidden) {
 | |
|         document.addEventListener('visibilitychange', drawMap, {once: true})
 | |
|       } else {
 | |
|         drawMap()
 | |
|       }
 | |
|     }, updateInterval)
 | |
|   }
 | |
|   // centers map to node and opens popup
 | |
|   const showNode = nodeNum => {
 | |
|     if (markersByNode[nodeNum] === undefined) {
 | |
|       return false
 | |
|     }
 | |
|     map.setView(markersByNode[nodeNum].getLatLng(), Math.max(window.localStorage.getItem('zoom') ?? 0, zoomLevelNode))
 | |
|     setTimeout(() => markersByNode[nodeNum].openPopup(), 300)
 | |
|     return true
 | |
|   }
 | |
|   // keep URL fragment in sync
 | |
|   window.addEventListener('hashchange', () => {
 | |
|     if (window.location.hash && !showNode(window.location.hash.slice(1))) {
 | |
|       history.replaceState(null, '', window.location.pathname)
 | |
|     }
 | |
|     if (!window.location.hash) {
 | |
|       map.closePopup()
 | |
|     }
 | |
|   })
 | |
|   map.on('popupclose', () => {
 | |
|     if (window.location.hash) {
 | |
|       history.replaceState(null, '', window.location.pathname)
 | |
|     }
 | |
|   })
 | |
|   // let's go!!!
 | |
|   drawMap().then(() => {
 | |
|     if (window.location.hash && !showNode(window.location.hash.slice(1))) {
 | |
|       history.replaceState(null, '', window.location.pathname)
 | |
|     }
 | |
|   })
 | |
| </script>
 |