| 
									
										
										
										
											2024-05-06 15:47:19 -07:00
										 |  |  | <!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> | 
					
						
							| 
									
										
										
										
											2024-12-28 20:41:41 -08:00
										 |  |  | <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"> | 
					
						
							| 
									
										
										
										
											2024-05-06 15:47:19 -07:00
										 |  |  | <link rel="manifest" href="/site.webmanifest"> | 
					
						
							|  |  |  | <style> | 
					
						
							| 
									
										
										
										
											2024-10-23 19:03:20 -07:00
										 |  |  |   html, body, #map { | 
					
						
							|  |  |  |     height: 100%; | 
					
						
							|  |  |  |     width: 100vw; | 
					
						
							| 
									
										
										
										
											2024-09-20 08:37:00 -07:00
										 |  |  |   } | 
					
						
							| 
									
										
										
										
											2024-05-06 15:47:19 -07:00
										 |  |  |   body { | 
					
						
							|  |  |  |     margin: 0; | 
					
						
							|  |  |  |     padding: 0; | 
					
						
							|  |  |  |   } | 
					
						
							| 
									
										
										
										
											2024-10-23 19:03:20 -07:00
										 |  |  |   body.dark { | 
					
						
							|  |  |  |     filter: invert(1) hue-rotate(180deg) brightness(1.25); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  |   body.dark .dark-hidden { | 
					
						
							|  |  |  |     display: none; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  |   body:not(.dark) .dark-only { | 
					
						
							|  |  |  |     display: none; | 
					
						
							| 
									
										
										
										
											2024-05-06 15:47:19 -07:00
										 |  |  |   } | 
					
						
							|  |  |  |   #header { | 
					
						
							|  |  |  |     background-color: #fff; | 
					
						
							|  |  |  |     box-shadow: 0 0 4px 0 rgb(0 0 0 / 40%); | 
					
						
							|  |  |  |     color: #333; | 
					
						
							|  |  |  |     display: flex; | 
					
						
							|  |  |  |     gap: 2ch; | 
					
						
							| 
									
										
										
										
											2024-12-28 20:41:41 -08:00
										 |  |  |     font-family: "Inter", sans-serif; | 
					
						
							| 
									
										
										
										
											2024-10-23 17:52:10 -07:00
										 |  |  |     padding: 0.75em; | 
					
						
							| 
									
										
										
										
											2024-05-06 15:47:19 -07:00
										 |  |  |     position: absolute; | 
					
						
							|  |  |  |     top: 0; | 
					
						
							|  |  |  |     left: 0; | 
					
						
							|  |  |  |     right: 0; | 
					
						
							|  |  |  |     z-index: 1000; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  |   #header a { | 
					
						
							|  |  |  |     color: inherit; | 
					
						
							|  |  |  |     text-decoration: none; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  |   #header a:hover { | 
					
						
							| 
									
										
										
										
											2024-10-24 14:07:50 -07:00
										 |  |  |     opacity: 0.7; | 
					
						
							| 
									
										
										
										
											2024-05-06 15:47:19 -07:00
										 |  |  |   } | 
					
						
							|  |  |  |   #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 { | 
					
						
							| 
									
										
										
										
											2024-10-23 17:52:10 -07:00
										 |  |  |     top: 4em; | 
					
						
							| 
									
										
										
										
											2024-05-06 15:47:19 -07:00
										 |  |  |   } | 
					
						
							| 
									
										
										
										
											2024-10-23 19:03:20 -07:00
										 |  |  |   .leaflet-tooltip, .leaflet-popup-content { | 
					
						
							| 
									
										
										
										
											2024-12-28 20:41:41 -08:00
										 |  |  |     font-family: "Inter", sans-serif; | 
					
						
							| 
									
										
										
										
											2024-05-06 15:47:19 -07:00
										 |  |  |     font-size: 12px; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  |   .leaflet-popup-content .title { | 
					
						
							|  |  |  |     font-size: 13px; | 
					
						
							|  |  |  |     font-weight: bold; | 
					
						
							|  |  |  |     margin-bottom: 3px; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  |   .leaflet-popup-content table { | 
					
						
							|  |  |  |     margin-top: 1em; | 
					
						
							|  |  |  |   } | 
					
						
							| 
									
										
										
										
											2024-10-23 19:03:20 -07:00
										 |  |  |   body.dark .leaflet-shadow-pane { | 
					
						
							|  |  |  |     display: none; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  |   body.dark :is(.leaflet-tooltip, .leaflet-popup-content-wrapper, .leaflet-popup-tip) { | 
					
						
							| 
									
										
										
										
											2024-10-31 18:09:37 -07:00
										 |  |  |     box-shadow: 0 0 4px 0 rgb(0 0 0 / 40%); | 
					
						
							| 
									
										
										
										
											2024-10-23 19:03:20 -07:00
										 |  |  |   } | 
					
						
							|  |  |  |   @media (hover: none) { | 
					
						
							|  |  |  |     .leaflet-tooltip-pane { | 
					
						
							|  |  |  |       display: none; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |   } | 
					
						
							| 
									
										
										
										
											2024-05-06 15:47:19 -07:00
										 |  |  | </style> | 
					
						
							|  |  |  | <div id="header"> | 
					
						
							| 
									
										
										
										
											2025-01-07 10:50:48 -08:00
										 |  |  |   <div> | 
					
						
							|  |  |  |     <a href="https://meshmap.net/" title="A nearly live map of Meshtastic nodes seen by the official Meshtastic MQTT server">MeshMap</a> | 
					
						
							|  |  |  |   </div> | 
					
						
							| 
									
										
										
										
											2024-10-23 19:03:20 -07:00
										 |  |  |   <div> | 
					
						
							| 
									
										
										
										
											2024-10-24 14:07:50 -07:00
										 |  |  |     <a | 
					
						
							|  |  |  |       href="#" | 
					
						
							|  |  |  |       onclick="window.localStorage.setItem('theme', document.body.classList.toggle('dark') ? 'dark' : 'light');return false" | 
					
						
							| 
									
										
										
										
											2024-10-23 19:03:20 -07:00
										 |  |  |       title="Toggle dark mode" | 
					
						
							| 
									
										
										
										
											2024-10-24 14:07:50 -07:00
										 |  |  |     ><i class="fa fa-moon-o fa-lg dark-hidden"></i><i class="fa fa-sun-o fa-lg dark-only"></i></a> | 
					
						
							| 
									
										
										
										
											2024-10-23 19:03:20 -07:00
										 |  |  |   </div> | 
					
						
							| 
									
										
										
										
											2024-10-24 14:07:50 -07:00
										 |  |  |   <div><a href="https://github.com/brianshea2/meshmap.net" title="GitHub"><i class="fa fa-github fa-lg"></i></a></div> | 
					
						
							| 
									
										
										
										
											2024-05-06 15:47:19 -07:00
										 |  |  |   <div><a href="https://meshtastic.org/">Meshtastic</a></div> | 
					
						
							|  |  |  | </div> | 
					
						
							|  |  |  | <div id="map"></div> | 
					
						
							| 
									
										
										
										
											2024-12-28 20:41:41 -08:00
										 |  |  | <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> | 
					
						
							| 
									
										
										
										
											2024-05-06 15:47:19 -07:00
										 |  |  | <script> | 
					
						
							|  |  |  |   const ipinfoToken = 'aeb066758afd49' | 
					
						
							|  |  |  |   const updateInterval = 65000 | 
					
						
							| 
									
										
										
										
											2024-06-11 13:16:33 -07:00
										 |  |  |   const zoomLevelNode = 10 | 
					
						
							| 
									
										
										
										
											2024-05-06 15:47:19 -07:00
										 |  |  |   const markersByNode = {} | 
					
						
							|  |  |  |   const neighborsByNode = {} | 
					
						
							| 
									
										
										
										
											2024-09-18 18:39:52 -07:00
										 |  |  |   const nodesBySearchString = {} | 
					
						
							| 
									
										
										
										
											2024-05-06 15:47:19 -07:00
										 |  |  |   const precisionMargins = [ | 
					
						
							| 
									
										
										
										
											2024-06-02 20:45:43 -07:00
										 |  |  |     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 | 
					
						
							| 
									
										
										
										
											2024-05-06 15:47:19 -07:00
										 |  |  |   ] | 
					
						
							| 
									
										
										
										
											2025-01-07 10:50:48 -08:00
										 |  |  |   // encodes html reserved characters and ascii control characters | 
					
						
							|  |  |  |   const html = str => str | 
					
						
							|  |  |  |     ?.replace(/[\x00-\x1F]/g, c => `\\x${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`) | 
					
						
							|  |  |  |     .replace(/["&<>]/g, c => `&#${c.charCodeAt(0)};`) | 
					
						
							| 
									
										
										
										
											2024-05-06 15:47:19 -07:00
										 |  |  |   // 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, | 
					
						
							| 
									
										
										
										
											2024-09-18 18:39:52 -07:00
										 |  |  |     propertyName: 'searchString', | 
					
						
							| 
									
										
										
										
											2024-05-06 15:47:19 -07:00
										 |  |  |     initial: false, | 
					
						
							|  |  |  |     position: 'topleft', | 
					
						
							|  |  |  |     marker: false, | 
					
						
							| 
									
										
										
										
											2024-09-18 18:39:52 -07:00
										 |  |  |     moveToLocation: (_, s) => showNode(nodesBySearchString[s]), | 
					
						
							| 
									
										
										
										
											2024-05-06 15:47:19 -07:00
										 |  |  |   })) | 
					
						
							| 
									
										
										
										
											2024-10-23 19:03:20 -07:00
										 |  |  |   // add zoom control | 
					
						
							|  |  |  |   L.control.zoom({position: 'topright'}).addTo(map) | 
					
						
							| 
									
										
										
										
											2024-05-06 15:47:19 -07:00
										 |  |  |   // add geolocation control | 
					
						
							|  |  |  |   L.easyButton({ | 
					
						
							| 
									
										
										
										
											2024-10-23 19:03:20 -07:00
										 |  |  |     position: 'topright', | 
					
						
							| 
									
										
										
										
											2024-05-06 15:47:19 -07:00
										 |  |  |     states: [ | 
					
						
							|  |  |  |       { | 
					
						
							|  |  |  |         stateName: 'geolocation-button', | 
					
						
							|  |  |  |         title: 'Center map to current IP geolocation', | 
					
						
							| 
									
										
										
										
											2024-10-24 14:07:50 -07:00
										 |  |  |         icon: 'fa-crosshairs fa-lg', | 
					
						
							| 
									
										
										
										
											2024-05-06 15:47:19 -07:00
										 |  |  |         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, | 
					
						
							| 
									
										
										
										
											2024-05-28 10:27:51 -07:00
										 |  |  |       fwVersion, region, modemPreset, hasDefaultCh, onlineLocalNodes, | 
					
						
							| 
									
										
										
										
											2024-08-18 15:53:28 -07:00
										 |  |  |       latitude, longitude, altitude, precision, | 
					
						
							| 
									
										
										
										
											2024-05-06 15:47:19 -07:00
										 |  |  |       batteryLevel, voltage, chUtil, airUtilTx, uptime, | 
					
						
							| 
									
										
										
										
											2025-02-05 14:49:37 -08:00
										 |  |  |       temperature, relativeHumidity, barometricPressure, lux, | 
					
						
							|  |  |  |       windDirection, windSpeed, windGust, radiation, rainfall1, rainfall24, | 
					
						
							| 
									
										
										
										
											2024-05-06 15:47:19 -07:00
										 |  |  |       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)) | 
					
						
							| 
									
										
										
										
											2024-09-28 11:46:04 -07:00
										 |  |  |     const lastSeen = Math.max(...Object.values(seenBy)) | 
					
						
							| 
									
										
										
										
											2024-10-04 18:13:50 -07:00
										 |  |  |     const opacity = 1.0 - (Date.now() / 1000 - lastSeen) / 129600 | 
					
						
							| 
									
										
										
										
											2024-09-28 11:46:04 -07:00
										 |  |  |     const tooltipContent = `${html(longName)} (${html(shortName)}) ${since(lastSeen)}` | 
					
						
							| 
									
										
										
										
											2024-05-06 15:47:19 -07:00
										 |  |  |     const popupContent = ` | 
					
						
							|  |  |  |       <div class="title">${html(longName)} (${html(shortName)})</div> | 
					
						
							|  |  |  |       <div>${nodeLink(nodeNum, id)} | ${html(role)} | ${html(hwModel)}</div> | 
					
						
							|  |  |  |       <table><tbody> | 
					
						
							| 
									
										
										
										
											2025-01-07 10:50:48 -08:00
										 |  |  |       ${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>`        : ''} | 
					
						
							| 
									
										
										
										
											2024-11-22 18:37:07 -08:00
										 |  |  |       ${barometricPressure ? `<tr><th>Barometric Pressure</th><td>${Math.round(barometricPressure)} hPa</td></tr>` : ''} | 
					
						
							| 
									
										
										
										
											2025-02-03 19:21:05 -08:00
										 |  |  |       ${lux                ? `<tr><th>Lux</th><td>${Math.round(lux)} lx</td></tr>`                                 : ''} | 
					
						
							|  |  |  |       ${windDirection || windSpeed ? `<tr><th>Wind</th><td>` + | 
					
						
							|  |  |  |                               (windDirection ? `${windDirection}°` : '') + | 
					
						
							|  |  |  |                               (windDirection && windSpeed ? ' @ ' : '') + | 
					
						
							|  |  |  |                               (windSpeed ? `${(windSpeed * 3.6).toFixed(1)}` : '') + | 
					
						
							|  |  |  |                               (windSpeed && windGust ? ` G ${(windGust * 3.6).toFixed(1)}` : '') + | 
					
						
							|  |  |  |                               (windSpeed ? ' km/h' : '') + | 
					
						
							|  |  |  |                               `</td></tr>`                                                                         : ''} | 
					
						
							|  |  |  |       ${radiation          ? `<tr><th>Radiation</th><td>${radiation.toFixed(2)} µR/h</td></tr>`                    : ''} | 
					
						
							| 
									
										
										
										
											2025-02-05 14:49:37 -08:00
										 |  |  |       ${rainfall1 || rainfall24 ? `<tr><th>Rainfall</th><td>` + | 
					
						
							|  |  |  |                               (rainfall1 ? `${rainfall1.toFixed(2)} mm/h` : '') + | 
					
						
							|  |  |  |                               (rainfall1 && rainfall24 ? ', ' : '') + | 
					
						
							|  |  |  |                               (rainfall24 ? `${rainfall24.toFixed(2)} mm/24h` : '') + | 
					
						
							|  |  |  |                               `</td></tr>`                                                                         : ''} | 
					
						
							| 
									
										
										
										
											2025-01-07 10:50:48 -08:00
										 |  |  |       ${altitude           ? `<tr><th>Altitude</th><td>${altitude.toLocaleString()} m above MSL</td></tr>`         : ''} | 
					
						
							| 
									
										
										
										
											2025-02-03 19:21:05 -08:00
										 |  |  |       ${precision && precisionMargins[precision-1] ? `<tr><th>Location precision</th><td>` + | 
					
						
							|  |  |  |                              `±${precisionMargins[precision-1].toLocaleString()} m (orange circle)</td></tr>` : ''} | 
					
						
							| 
									
										
										
										
											2024-05-06 15:47:19 -07:00
										 |  |  |       </tbody></table> | 
					
						
							|  |  |  |       <table><thead> | 
					
						
							| 
									
										
										
										
											2024-06-11 15:04:27 -07:00
										 |  |  |       <tr><th>Last seen</th><th>via</th><th>root topic</th><th>channel</th></tr> | 
					
						
							| 
									
										
										
										
											2024-05-06 15:47:19 -07:00
										 |  |  |       </thead><tbody> | 
					
						
							| 
									
										
										
										
											2024-07-24 20:01:55 -07:00
										 |  |  |       ${Array.from( | 
					
						
							|  |  |  |         new Map( | 
					
						
							|  |  |  |           Object.entries(seenBy) | 
					
						
							| 
									
										
										
										
											2025-01-07 10:50:48 -08:00
										 |  |  |             .map(([topic, seen]) => (m => ({seen, via: m[3] ?? id, root: m[1], chan: m[2]}))( | 
					
						
							|  |  |  |               topic.match(/^(.*)(?:\/2\/e\/(.*)\/(![0-9a-f]+)|\/2\/map\/)$/s) | 
					
						
							|  |  |  |             )) | 
					
						
							| 
									
										
										
										
											2024-07-24 20:01:55 -07:00
										 |  |  |             .sort((a, b) => a.seen - b.seen) | 
					
						
							|  |  |  |             .map(v => [v.via, v]) | 
					
						
							|  |  |  |         ).values(), | 
					
						
							|  |  |  |         ({seen, via, root, chan}) => ` | 
					
						
							| 
									
										
										
										
											2024-06-11 15:04:27 -07:00
										 |  |  |           <tr> | 
					
						
							|  |  |  |           <td>${since(seen)}</td> | 
					
						
							| 
									
										
										
										
											2024-07-24 20:01:55 -07:00
										 |  |  |           <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> | 
					
						
							| 
									
										
										
										
											2024-06-11 15:04:27 -07:00
										 |  |  |           </tr> | 
					
						
							|  |  |  |         ` | 
					
						
							| 
									
										
										
										
											2024-07-24 20:01:55 -07:00
										 |  |  |       ).reverse().join('')} | 
					
						
							| 
									
										
										
										
											2024-05-06 15:47:19 -07:00
										 |  |  |       </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)}` | 
					
						
							| 
									
										
										
										
											2024-09-18 18:39:52 -07:00
										 |  |  |         const neighborContent = ` | 
					
						
							| 
									
										
										
										
											2024-05-06 15:47:19 -07:00
										 |  |  |           <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}) | 
					
						
							| 
									
										
										
										
											2024-10-23 19:18:14 -07:00
										 |  |  |           .bindTooltip(neighborContent, {sticky: true}) | 
					
						
							| 
									
										
										
										
											2024-05-06 15:47:19 -07:00
										 |  |  |           .on('click', () => showNode(neighborNum)) | 
					
						
							|  |  |  |           .addTo(detailsLayer) | 
					
						
							|  |  |  |       }) | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |     if (markersByNode[nodeNum] === undefined) { | 
					
						
							| 
									
										
										
										
											2024-09-18 18:39:52 -07:00
										 |  |  |       const searchString = `${longName} (${shortName}) ${id}` | 
					
						
							|  |  |  |       nodesBySearchString[searchString] = nodeNum | 
					
						
							| 
									
										
										
										
											2024-09-28 11:46:04 -07:00
										 |  |  |       markersByNode[nodeNum] = L.marker(position, {alt: 'Node', opacity, searchString}) | 
					
						
							| 
									
										
										
										
											2024-09-18 18:39:52 -07:00
										 |  |  |         .bindTooltip(tooltipContent) | 
					
						
							| 
									
										
										
										
											2024-05-06 15:47:19 -07:00
										 |  |  |         .bindPopup(popupContent, {maxWidth: 500}) | 
					
						
							|  |  |  |         .on('popupopen', () => { | 
					
						
							|  |  |  |           history.replaceState(null, '', `#${nodeNum}`) | 
					
						
							|  |  |  |           populateDetailsLayer() | 
					
						
							|  |  |  |         }) | 
					
						
							|  |  |  |         .addTo(markers) | 
					
						
							|  |  |  |     } else { | 
					
						
							| 
									
										
										
										
											2024-09-28 11:46:04 -07:00
										 |  |  |       markersByNode[nodeNum].setOpacity(opacity) | 
					
						
							| 
									
										
										
										
											2024-09-18 18:39:52 -07:00
										 |  |  |       markersByNode[nodeNum].setTooltipContent(tooltipContent) | 
					
						
							| 
									
										
										
										
											2024-05-06 15:47:19 -07:00
										 |  |  |       markersByNode[nodeNum].setPopupContent(popupContent) | 
					
						
							|  |  |  |       markersByNode[nodeNum].setLatLng(position) | 
					
						
							|  |  |  |       if (markersByNode[nodeNum].isPopupOpen()) { | 
					
						
							|  |  |  |         populateDetailsLayer() | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |   }) | 
					
						
							|  |  |  |   // fetches node data, updates map, repeats | 
					
						
							| 
									
										
										
										
											2025-01-06 16:50:17 -08:00
										 |  |  |   const drawMap = async () => { | 
					
						
							|  |  |  |     try { | 
					
						
							|  |  |  |       await fetch('/nodes.json').then(r => r.json()).then(updateNodes) | 
					
						
							|  |  |  |     } catch (e) { | 
					
						
							|  |  |  |       console.error('Failed to update nodes:', e) | 
					
						
							| 
									
										
										
										
											2024-05-06 15:47:19 -07:00
										 |  |  |     } | 
					
						
							| 
									
										
										
										
											2025-01-06 16:50:17 -08:00
										 |  |  |     setTimeout(() => { | 
					
						
							|  |  |  |       if (document.hidden) { | 
					
						
							|  |  |  |         document.addEventListener('visibilitychange', drawMap, {once: true}) | 
					
						
							|  |  |  |       } else { | 
					
						
							|  |  |  |         drawMap() | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |     }, updateInterval) | 
					
						
							| 
									
										
										
										
											2024-05-06 15:47:19 -07:00
										 |  |  |   } | 
					
						
							|  |  |  |   // 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!!! | 
					
						
							| 
									
										
										
										
											2025-01-06 16:50:17 -08:00
										 |  |  |   drawMap().then(() => { | 
					
						
							| 
									
										
										
										
											2024-05-06 15:47:19 -07:00
										 |  |  |     if (window.location.hash && !showNode(window.location.hash.slice(1))) { | 
					
						
							|  |  |  |       history.replaceState(null, '', window.location.pathname) | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |   }) | 
					
						
							|  |  |  | </script> |