meshmap.net/website/index.html
2024-09-19 01:39:52 +00:00

363 lines
13 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="manifest" href="/site.webmanifest">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">
<link rel="stylesheet" href="https://unpkg.com/leaflet-easybutton@2.4.0/src/easy-button.css" crossorigin="">
<link rel="stylesheet" href="https://unpkg.com/leaflet-search@4.0.0/dist/leaflet-search.min.css" crossorigin="">
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" crossorigin="">
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" crossorigin="">
<link rel="stylesheet" href="https://unpkg.com/font-awesome@4.7.0/css/font-awesome.min.css" crossorigin="">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap" crossorigin="">
<style>
body {
margin: 0;
padding: 0;
}
html, body, #map {
height: 100%;
width: 100vw;
}
#header {
background-color: #fff;
box-shadow: 0 0 4px 0 rgb(0 0 0 / 40%);
color: #333;
display: flex;
gap: 2ch;
font-family: "Roboto", sans-serif;
padding: 0.5em 1em;
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 1000;
}
#header a {
color: inherit;
text-decoration: none;
}
#header a:hover {
text-decoration: underline;
}
#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;
}
.dark {
filter: invert(1) hue-rotate(180deg) brightness(1.25);
}
.dark .leaflet-shadow-pane {
display: none;
}
.dark :is(.leaflet-tooltip, .leaflet-popup-content-wrapper, .leaflet-popup-tip) {
box-shadow: none;
}
.leaflet-top {
top: 3em;
}
.leaflet-tooltip-pane {
z-index: 750;
}
.leaflet-popup-content {
font-family: "Roboto", 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;
}
</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.net</a></div>
<div><a href="https://meshtastic.org/">Meshtastic</a></div>
<div><a href="https://github.com/brianshea2/meshmap.net">GitHub</a></div>
</div>
<div id="map"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<script src="https://unpkg.com/leaflet-easybutton@2.4.0/src/easy-button.js" crossorigin=""></script>
<script src="https://unpkg.com/leaflet-search@4.0.0/dist/leaflet-search.min.js" crossorigin=""></script>
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js" crossorigin=""></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: '&copy; <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 geolocation control
L.easyButton({
position: 'topleft',
states: [
{
stateName: 'geolocation-button',
title: 'Center map to current IP geolocation',
icon: 'fa-crosshairs',
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)
// add theme control
L.easyButton({
position: 'topright',
states: [
{
stateName: 'theme-light',
title: 'Toggle dark mode',
icon: 'fa-sun-o',
onClick: btn => {
const dark = document.body.classList.toggle('dark')
window.localStorage.setItem('theme', dark ? 'dark' : 'light')
btn.state(dark ? 'theme-dark' : 'theme-light')
},
},
{
stateName: 'theme-dark',
title: 'Toggle dark mode',
icon: 'fa-moon-o',
onClick: btn => {
const dark = document.body.classList.toggle('dark')
window.localStorage.setItem('theme', dark ? 'dark' : 'light')
btn.state(dark ? 'theme-dark' : 'theme-light')
},
},
],
}).state(document.body.classList.contains('dark') ? 'theme-dark' : 'theme-light').addTo(map)
// add zoom control
L.control.zoom({position: 'bottomleft'}).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,
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 tooltipContent = `${html(longName)} (${html(shortName)}) ${since(Math.max(...Object.values(seenBy)))}`
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>` : ''}
${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>` : ''}
${onlineLocalNodes ? `<tr><th>Online local nodes</th><td>${onlineLocalNodes}</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>&#177; ${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)} &#60;-&#62; ${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, opacity: 1.0})
.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', searchString})
.bindTooltip(tooltipContent)
.bindPopup(popupContent, {maxWidth: 500})
.on('popupopen', () => {
history.replaceState(null, '', `#${nodeNum}`)
populateDetailsLayer()
})
.addTo(markers)
} else {
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 shouldDraw => {
if (shouldDraw) {
try {
await fetch('/nodes.json').then(r => r.json()).then(updateNodes)
} catch (e) {
console.error('Failed to update nodes:', e)
}
}
setTimeout(() => drawMap(document.visibilityState === 'visible'), 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(true).then(() => {
if (window.location.hash && !showNode(window.location.hash.slice(1))) {
history.replaceState(null, '', window.location.pathname)
}
})
</script>