// global state
var nodes = [];
var nodeMarkers = {};
var selectedNodeOutlineCircle = null;
var waypoints = [];
// set map bounds to be a little more than full size to prevent panning off screen
var bounds = [
[-100, 70], // top left
[100, 500], // bottom right
];
// create map positioned over NRW
if(!isMobile()){
var map = L.map('map', {
maxBounds: bounds,
}).setView([
51.1,
366.82,
], 9);
} else {
var map = L.map('map', {
maxBounds: bounds,
}).setView([
51.1,
366.82,
], 8);
}
// remove leaflet link
map.attributionControl.setPrefix('');
var openThunderforestLandscapeMapTileLayer = L.tileLayer('https://tiles.nixware.dev/landscape/{z}/{x}/{y}.png', {
maxZoom: 22,
attribution: 'Tiles © Gravitystorm Limited | Data from Meshtastic ',
});
var openThunderforestAtlasMapTileLayer = L.tileLayer('https://tiles.nixware.dev/atlas/{z}/{x}/{y}.png', {
maxZoom: 22,
attribution: 'Tiles © Gravitystorm Limited | Data from Meshtastic ',
});
var openThunderforestNeighbourhoodMapTileLayer = L.tileLayer('https://tiles.nixware.dev/neighbourhood/{z}/{x}/{y}.png', {
maxZoom: 22,
attribution: 'Tiles © Gravitystorm Limited | Data from Meshtastic ',
});
var openStreetMapTileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 22, // increase from 18 to 22
attribution: 'Tiles © OpenStreetMap | Data from Meshtastic ',
});
var openTopoMapTileLayer = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
maxZoom: 17, // open topo map doesn't have tiles closer than this
attribution: 'Tiles © OpenStreetMap | Data from Meshtastic ',
});
var esriWorldImageryTileLayer = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
maxZoom: 21, // esri doesn't have tiles closer than this
attribution: 'Tiles © Esri | Data from Meshtastic '
});
var googleSatelliteTileLayer = L.tileLayer('https://{s}.google.com/vt/lyrs=s&x={x}&y={y}&z={z}', {
maxZoom: 21,
subdomains: ['mt0', 'mt1', 'mt2', 'mt3'],
attribution: 'Tiles © Google | Data from Meshtastic '
});
var googleHybridTileLayer = L.tileLayer('https://{s}.google.com/vt/lyrs=s,h&x={x}&y={y}&z={z}', {
maxZoom: 21,
subdomains: ['mt0', 'mt1', 'mt2', 'mt3'],
attribution: 'Tiles © Google | Data from Meshtastic '
});
var tileLayers = {
"Thunderforest Neighbourhood": openThunderforestNeighbourhoodMapTileLayer,
"Thunderforest Landscape": openThunderforestLandscapeMapTileLayer,
"Thunderforest Atlas": openThunderforestAtlasMapTileLayer,
"OpenStreetMap": openStreetMapTileLayer,
"OpenTopoMap": openTopoMapTileLayer,
"Esri Satellite": esriWorldImageryTileLayer,
"Google Satellite": googleSatelliteTileLayer,
"Google Hybrid": googleHybridTileLayer,
};
// use tile layer based on config
const selectedTileLayerName = getConfigMapSelectedTileLayer();
const selectedTileLayer = tileLayers[selectedTileLayerName] || openThunderforestNeighbourhoodMapTileLayer;
selectedTileLayer.addTo(map);
// create layer groups
var nodesLayerGroup = new L.LayerGroup();
var backboneConnectionsLayerGroup = new L.LayerGroup();
var nodeConnectionsLayerGroup = new L.LayerGroup();
var nodesClusteredLayerGroup = L.markerClusterGroup({
showCoverageOnHover: false,
disableClusteringAtZoom: 10, // zoom level where node clustering is disabled
});
var nodesRouterLayerGroup = L.markerClusterGroup({
showCoverageOnHover: false,
disableClusteringAtZoom: 10, // zoom level where node clustering is disabled
});
var nodesBackboneLayerGroup = new L.LayerGroup();
//var nodesMediumFastLayerGroup = new L.LayerGroup();
var nodesShortSlowLayerGroup = new L.LayerGroup();
var nodesLongFastLayerGroup = new L.LayerGroup();
var waypointsLayerGroup = new L.LayerGroup();
var nodePositionHistoryLayerGroup = new L.LayerGroup();
var traceroutesLayerGroup = new L.LayerGroup();
var connectionsLayerGroup = new L.LayerGroup();
// create icons
var iconMqttConnected = L.divIcon({
className: 'icon-mqtt-connected',
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
});
var iconLongFast = L.divIcon({
className: 'icon-longfast',
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
});
/*var iconMediumFast = L.divIcon({
className: 'icon-mediumfast',
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
});*/
var iconShortSlow = L.divIcon({
className: 'icon-shortslow',
iconSize: [16, 16],
});
var iconMqttDisconnected = L.divIcon({
className: 'icon-mqtt-disconnected',
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
});
var iconOffline = L.divIcon({
className: 'icon-offline',
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
});
var iconPositionHistory = L.divIcon({
className: 'icon-position-history',
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
});
var iconTracerouteStart = L.divIcon({
className: 'icon-traceroute-start',
iconSize: [16, 16],
});
var iconTracerouteEnd = L.divIcon({
className: 'icon-traceroute-end',
iconSize: [16, 16],
});
// create legend
var legendLayerGroup = new L.LayerGroup();
var legend = L.control({position: 'bottomleft'});
legend.onAdd = function (map) {
var div = L.DomUtil.create('div', 'leaflet-control-layers');
div.style.backgroundColor = 'white';
div.style.padding = '12px';
div.innerHTML = `
Legend
`
+ ``
//+ ``
+ ``
+ ``
+ ` Traceroute
`;
return div;
};
// handle baselayerchange to update tile layer preference
map.on('baselayerchange', function(event) {
setConfigMapSelectedTileLayer(event.name);
});
// handle adding/remove legend on map (can't use L.Control as an overlay, so we toggle an empty L.LayerGroup)
map.on('overlayadd overlayremove', function(event) {
if(event.name === "Legend"){
if(event.type === "overlayadd"){
map.addControl(legend);
} else if(event.type === "overlayremove"){
map.removeControl(legend);
}
}
});
// add layers to control ui
L.control.groupedLayers(tileLayers, {
"Nodes": {
"All": nodesLayerGroup,
"Routers": nodesRouterLayerGroup,
"Backbone": nodesBackboneLayerGroup,
"ShortSlow": nodesShortSlowLayerGroup,
//"MediumFast": nodesMediumFastLayerGroup,
"LongFast": nodesLongFastLayerGroup,
"Clustered": nodesClusteredLayerGroup,
"None": new L.LayerGroup(),
},
"Overlays": {
"Legend": legendLayerGroup,
"Backbone Connections": backboneConnectionsLayerGroup,
"Connections": connectionsLayerGroup,
"Waypoints": waypointsLayerGroup,
"Position History": nodePositionHistoryLayerGroup,
"Traceroutes": traceroutesLayerGroup,
},
}, {
// make the "Nodes" group exclusive (use radio inputs instead of checkbox)
exclusiveGroups: ["Nodes"],
}).addTo(map);
// enable base layers
nodesLayerGroup.addTo(map);
// enable overlay layers based on config
const enabledOverlayLayers = getConfigMapEnabledOverlayLayers();
if(enabledOverlayLayers.includes("Legend")){
legendLayerGroup.addTo(map);
}
if(enabledOverlayLayers.includes("Backbone Connection")){
backboneConnectionsLayerGroup.addTo(map);
}
if(enabledOverlayLayers.includes("Connections")){
connectionsLayerGroup.addTo(map);
}
if(enabledOverlayLayers.includes("Waypoints")){
waypointsLayerGroup.addTo(map);
}
if(enabledOverlayLayers.includes("Position History")){
nodePositionHistoryLayerGroup.addTo(map);
}
if(enabledOverlayLayers.includes("Traceroutes")){
traceroutesLayerGroup.addTo(map);
}
map.on('overlayadd', function(event) {
// update config when map overlay is added
const layerName = event.name;
const enabledOverlayLayers = getConfigMapEnabledOverlayLayers();
if(!enabledOverlayLayers.includes(layerName)){
enabledOverlayLayers.push(layerName);
}
setConfigMapEnabledOverlayLayers(enabledOverlayLayers);
// clear traceroutes layer when traceroutes overlay is added
if (layerName === "Traceroutes") {
traceroutesLayerGroup.clearLayers();
}
});
// update config when map overlay is removed
map.on('overlayremove', function(event) {
const layerName = event.name;
const enabledOverlayLayers = getConfigMapEnabledOverlayLayers().filter(function(enabledOverlayLayer) {
return enabledOverlayLayer !== layerName;
});
setConfigMapEnabledOverlayLayers(enabledOverlayLayers);
});
// handle map clicks
map.on('click', function() {
// remove outline when map clicked
clearNodeOutline();
// send callback to vue
window._onMapClick();
});
// close all tooltips and popups when clicking map
map.on("click", function(event) {
// do nothing when clicking inside tooltip
const clickedElement = event.originalEvent.target;
if(elementOrAnyAncestorHasClass(clickedElement, "leaflet-tooltip")){
return;
}
closeAllTooltips();
closeAllPopups();
});
function isValidLatLng(lat, lng) {
if(isNaN(lat) || isNaN(lng)){
return false;
}
return true;
}
function findNodeById(id) {
// find node by id
var node = nodes.find((node) => node.node_id.toString() === id.toString());
if(node){
return node;
}
return null;
}
function findNodeMarkerById(id) {
// find node marker by id
var nodeMarker = nodeMarkers[id];
if(nodeMarker){
return nodeMarker;
}
return null;
}
function goToNode(id, animate, zoom){
// find node
var node = findNodeById(id);
if(!node){
alert("Could not find node: " + id);
return false;
}
// find node marker by id
var nodeMarker = findNodeMarkerById(id);
if(!nodeMarker){
return false;
}
// close all popups and tooltips
closeAllPopups();
closeAllTooltips();
// select node
showNodeOutline(id);
// fly to node marker
const shouldAnimate = animate != null ? animate : true;
map.flyTo(nodeMarker.getLatLng(), zoom || getConfigZoomLevelGoToNode(), {
animate: getConfigEnableMapAnimations() ? shouldAnimate : false,
});
// open tooltip for node
map.openTooltip(getTooltipContentForNode(node), nodeMarker.getLatLng(), {
interactive: true, // allow clicking buttons inside tooltip
permanent: true, // don't auto dismiss when clicking buttons inside tooltip
});
// successfully went to node
return true;
}
function goToRandomNode() {
if(nodes.length > 0){
const randomNode = nodes[Math.floor(Math.random() * nodes.length)];
if(randomNode){
// go to node
if(window.goToNode(randomNode.node_id)){
return;
}
// fallback to showing node details since we can't go to the node
window.showNodeDetails(randomNode.node_id);
}
}
}
function clearAllNodes() {
nodesLayerGroup.clearLayers();
nodesClusteredLayerGroup.clearLayers();
nodesRouterLayerGroup.clearLayers();
nodesBackboneLayerGroup.clearLayers();
nodesShortSlowLayerGroup.clearLayers();
//nodesMediumFastLayerGroup.clearLayers();
nodesLongFastLayerGroup.clearLayers();
}
function clearAllBackboneConnections() {
backboneConnectionsLayerGroup.clearLayers();
}
function clearAllWaypoints() {
waypointsLayerGroup.clearLayers();
}
function clearAllTraceroutes() {
traceroutesLayerGroup.clearLayers();
}
function clearAllConnections() {
connectionsLayerGroup.clearLayers();
backboneConnectionsLayerGroup.clearLayers();
}
function closeAllPopups() {
map.eachLayer(function(layer) {
if(layer.options.pane === "popupPane"){
layer.removeFrom(map);
}
});
}
function closeAllTooltips() {
map.eachLayer(function(layer) {
if(layer.options.pane === "tooltipPane"){
layer.removeFrom(map);
}
});
}
function clearAllPositionHistory() {
nodePositionHistoryLayerGroup.clearLayers();
}
function clearNodeOutline() {
if(selectedNodeOutlineCircle){
selectedNodeOutlineCircle.removeFrom(map);
selectedNodeOutlineCircle = null;
}
}
function showNodeOutline(id) {
// remove any existing node circle
clearNodeOutline();
// find node marker by id
const nodeMarker = nodeMarkers[id];
if(!nodeMarker){
return;
}
// find node by id
const node = findNodeById(id);
if(!node){
return;
}
// add position precision circle around node
if(node.position_precision != null && node.position_precision > 0 && node.position_precision < 32){
selectedNodeOutlineCircle = L.circle(nodeMarker.getLatLng(), {
radius: getPositionPrecisionInMeters(node.position_precision),
}).addTo(map);
}
}
function showNodeDetails(id) {
// find node
const node = findNodeById(id);
if(!node){
return;
}
// fire callback to vuejs handler
window._onNodeClick(node);
}
function getColourForSnr(snr) {
if(snr >= -4) return "#16a34a"; // good
if(snr > -8) return "#fff200"; // medium-good
if(snr > -12) return "#ff9f1c"; // medium
return "#dc2626"; // bad
}
function getSignalBarsIndicator(snrDb) {
if(snrDb == null) return '';
// Determine number of bars based on SNR
let bars = 0;
if(snrDb >= -4) bars = 4; // good
else if(snrDb > -8) bars = 3; // medium-good
else if(snrDb > -12) bars = 2; // medium
else bars = 1; // bad
const color = getColourForSnr(snrDb);
// Create 4 bars with increasing height
let indicator = '';
// Bar heights: 4px, 6px, 8px, 10px
const barHeights = [4, 6, 8, 10];
const barWidth = 2;
for (let i = 0; i < 4; i++) {
const height = barHeights[i];
const isActive = i < bars;
const barColor = isActive ? color : '#d1d5db'; // gray for inactive bars
indicator += ` `;
}
indicator += ' ';
return indicator;
}
function cleanUpNodeConnections() {
// close tooltips and popups
closeAllPopups();
closeAllTooltips();
// setup node connections layer
nodeConnectionsLayerGroup.clearLayers();
nodeConnectionsLayerGroup.removeFrom(map);
nodeConnectionsLayerGroup.addTo(map);
}
function getTerrainProfileImage(node1, node2) {
// line colour between nodes
const lineColour = "0000FF"; // blue
// node 1 (left side of image)
const node1MarkerColour = "0000FF"; // blue
const node1Latitude = node1.latitude;
const node1Longitude = node1.longitude;
const node1ElevationMSL = node1.altitude ?? "";
// node 2 (right side of image)
const node2MarkerColour = "0000FF"; // blue
const node2Latitude = node2.latitude;
const node2Longitude = node2.longitude;
const node2ElevationMSL = node2.altitude ?? "";
// generate terrain profile image url
return "https://heywhatsthat.com/bin/profile-0904.cgi?" + new URLSearchParams({
src: "meshtastic.liamcottle.net",
axes: 1, // include grid lines and a scale
metric: 1, // show metric units
curvature: 1,
width: 500,
height: 200,
pt0: `${node1Latitude},${node1Longitude},${lineColour},${node1ElevationMSL},${node1MarkerColour}`,
pt1: `${node2Latitude},${node2Longitude},${lineColour},${node2ElevationMSL},${node2MarkerColour}`,
}).toString();
}
async function showNodeConnections(id) {
cleanUpNodeConnections();
// find node
const node = findNodeById(id);
if(!node){
return;
}
// find node marker
const nodeMarker = findNodeMarkerById(node.node_id);
if(!nodeMarker){
return;
}
// show overlay for node connections
window._onShowNodeConnectionsClick(node);
// Fetch connections for this node
const connectionsTimePeriodSec = getConfigConnectionsTimePeriodInSeconds();
const connectionsTimeFrom = connectionsTimePeriodSec ? (Date.now() - connectionsTimePeriodSec * 1000) : undefined;
const connectionsParams = new URLSearchParams();
connectionsParams.set('node_id', id);
if (connectionsTimeFrom) connectionsParams.set('time_from', connectionsTimeFrom);
try {
const response = await window.axios.get(`/api/v1/connections?${connectionsParams.toString()}`);
const connections = response.data.connections ?? [];
for (const connection of connections) {
// Convert to numbers for comparison since API returns strings
const nodeA = parseInt(connection.node_a, 10);
const nodeB = parseInt(connection.node_b, 10);
const otherNodeId = nodeA === id ? nodeB : nodeA;
const otherNode = findNodeById(otherNodeId);
const otherNodeMarker = findNodeMarkerById(otherNodeId);
if (!otherNode || !otherNodeMarker) continue;
// Apply bidirectional filter
const configConnectionsBidirectionalOnly = getConfigConnectionsBidirectionalOnly();
if(configConnectionsBidirectionalOnly){
const hasDirectionAB = connection.direction_ab && connection.direction_ab.avg_snr_db != null;
const hasDirectionBA = connection.direction_ba && connection.direction_ba.avg_snr_db != null;
if(!hasDirectionAB || !hasDirectionBA){
continue;
}
}
// Apply minimum SNR filter
const configConnectionsMinSnrDb = getConfigConnectionsMinSnrDb();
if(configConnectionsMinSnrDb != null){
const snrAB = connection.direction_ab && connection.direction_ab.avg_snr_db != null ? connection.direction_ab.avg_snr_db : null;
const snrBA = connection.direction_ba && connection.direction_ba.avg_snr_db != null ? connection.direction_ba.avg_snr_db : null;
const configConnectionsBidirectionalMinSnr = getConfigConnectionsBidirectionalMinSnr();
let hasSnrAboveThreshold;
if(configConnectionsBidirectionalMinSnr){
// Bidirectional mode: ALL existing directions must meet threshold
const directionsToCheck = [];
if(snrAB != null) directionsToCheck.push(snrAB);
if(snrBA != null) directionsToCheck.push(snrBA);
if(directionsToCheck.length === 0){
// No SNR data in either direction, skip
hasSnrAboveThreshold = false;
} else {
// All existing directions must be above threshold
hasSnrAboveThreshold = directionsToCheck.every(snr => snr > configConnectionsMinSnrDb);
}
} else {
// Default mode: EITHER direction has SNR above threshold
hasSnrAboveThreshold = (snrAB != null && snrAB > configConnectionsMinSnrDb) || (snrBA != null && snrBA > configConnectionsMinSnrDb);
}
if(!hasSnrAboveThreshold){
continue;
}
}
// Calculate distance
const distanceInMeters = nodeMarker.getLatLng().distanceTo(otherNodeMarker.getLatLng()).toFixed(2);
const configConnectionsMaxDistanceInMeters = getConfigConnectionsMaxDistanceInMeters();
if(configConnectionsMaxDistanceInMeters != null && parseFloat(distanceInMeters) > configConnectionsMaxDistanceInMeters){
continue;
}
let distance = `${distanceInMeters} meters`;
if (distanceInMeters >= 1000) {
const distanceInKilometers = (distanceInMeters / 1000).toFixed(2);
distance = `${distanceInKilometers} kilometers`;
}
// Determine line color
const configConnectionsColoredLines = getConfigConnectionsColoredLines();
const worstSnrDb = connection.worst_avg_snr_db;
const lineColor = configConnectionsColoredLines && worstSnrDb != null ? getColourForSnr(worstSnrDb) : '#2563eb';
// Create bidirectional line
const line = L.polyline([
nodeMarker.getLatLng(),
otherNodeMarker.getLatLng(),
], {
color: lineColor,
opacity: 0.75,
weight: 3,
}).addTo(nodeConnectionsLayerGroup);
// Generate tooltip using standardized function
const tooltipNodeA = findNodeById(connection.node_a);
const tooltipNodeB = findNodeById(connection.node_b);
const tooltip = generateConnectionTooltip(connection, tooltipNodeA, tooltipNodeB, distance);
line.bindTooltip(tooltip, {
sticky: true,
opacity: 1,
interactive: true,
})
.bindPopup(tooltip)
.on('click', function(event) {
event.target.closeTooltip();
});
}
} catch (err) {
console.error('Error fetching connections:', err);
}
}
function clearMap() {
closeAllPopups();
closeAllTooltips();
clearAllNodes();
clearAllBackboneConnections();
clearAllWaypoints();
clearAllTraceroutes();
clearAllConnections();
clearNodeOutline();
cleanUpNodeConnections();
}
// returns true if the element or one of its parents has the class classname
function elementOrAnyAncestorHasClass(element, className) {
// check if element contains class
if(element.classList && element.classList.contains(className)){
return true;
}
// check if parent node has the class
if(element.parentNode){
return elementOrAnyAncestorHasClass(element.parentNode, className);
}
// couldn't find the class
return false;
}
// escape strings for tooltips etc, to prevent html/script injection
// not used in vuejs, as that auto escapes
function escapeString(string) {
return string.replace(//g, ">");
}
function onNodesUpdated(updatedNodes) {
// clear nodes cache
nodes = [];
// get config
const now = moment();
const configNodesMaxAgeInSeconds = getConfigNodesMaxAgeInSeconds();
const configNodesOfflineAgeInSeconds = getConfigNodesOfflineAgeInSeconds();
const configConnectionsMaxDistanceInMeters = getConfigConnectionsMaxDistanceInMeters();
// add nodes
for(const node of updatedNodes){
// skip nodes older than configured node max age
if(configNodesMaxAgeInSeconds){
const lastUpdatedAgeInMillis = now.diff(moment(node.updated_at));
if(lastUpdatedAgeInMillis > configNodesMaxAgeInSeconds * 1000){
continue;
}
}
// add to cache
nodes.push(node);
// skip nodes without position
if(!node.latitude || !node.longitude){
continue;
}
// fix lat long
node.latitude = node.latitude / 10000000;
node.longitude = node.longitude / 10000000;
// skip nodes with invalid position
if(!isValidLatLng(node.latitude, node.longitude)){
continue;
}
// wrap longitude for shortest path, everything to left of australia should be shown on the right
var longitude = parseFloat(node.longitude);
if(longitude <= 100){
longitude += 360;
}
// icon based on channel preset
var icon = iconLongFast;
if (node.channel_id == "ShortSlow") {
icon = iconShortSlow;
}
/*if (node.channel_id == "MediumFast") {
icon = iconMediumFast;
}*/
// use offline icon for nodes older than configured node offline age
if(configNodesOfflineAgeInSeconds){
const lastUpdatedAgeInMillis = now.diff(moment(node.updated_at));
if(lastUpdatedAgeInMillis > configNodesOfflineAgeInSeconds * 1000){
icon = iconOffline;
}
}
// determine zIndexOffset: MediumFast (1000), LongFast (-1000), Offline (-2000)
var zIndexOffset = 1000;
if(icon == iconOffline){
zIndexOffset = -2000;
} else if(node.channel_id == 'LongFast'){
zIndexOffset = -1000;
}
// To not have overlapping nodes.
var latJitter = 0;
var lonJitter = 0;
// If position pression > 45m apply random jitter within a small circle to avoid diagonal-only displacement
if (node.position_precision < 19) {
const maxMeters = 40;
const r = maxMeters * Math.sqrt(Math.random());
const theta = 2 * Math.PI * Math.random();
const dy = r * Math.sin(theta);
const dx = r * Math.cos(theta);
const metersPerDegLat = 111320;
const metersPerDegLon = 111320 * Math.cos(node.latitude * Math.PI / 180);
latJitter = dy / metersPerDegLat;
lonJitter = metersPerDegLon ? (dx / metersPerDegLon) : 0;
}
// create node marker
const marker = L.marker([node.latitude + latJitter, longitude + lonJitter], {
icon: icon,
tagName: node.node_id,
// zIndex: offline (-2000) < has channel_id (-1000) < others (1000)
zIndexOffset: zIndexOffset,
}).on('click', function(event) {
// close tooltip on click to prevent tooltip and popup showing at same time
event.target.closeTooltip();
});
// add marker to node layer groups
marker.addTo(nodesLayerGroup);
nodesClusteredLayerGroup.addLayer(marker);
// add markers for routers and repeaters to routers layer group
if(node.role_name === "ROUTER"
|| node.role_name === "ROUTER_CLIENT"
|| node.role_name === "ROUTER_LATE"
|| node.role_name === "REPEATER"){
nodesRouterLayerGroup.addLayer(marker);
}
// add markers for backbone to layer group
if(node.is_backbone) {
nodesBackboneLayerGroup.addLayer(marker);
}
if(node.channel_id == "ShortSlow") {
nodesShortSlowLayerGroup.addLayer(marker);
}
// add markers for MediumFast channel to layer group
/*if(node.channel_id == "MediumFast") {
nodesMediumFastLayerGroup.addLayer(marker);
}*/
// add markers for LongFast channel to layer group
if(node.channel_id == "LongFast") {
nodesLongFastLayerGroup.addLayer(marker);
}
// show tooltip on desktop only
if(!isMobile()){
marker.bindTooltip(getTooltipContentForNode(node), {
interactive: true,
});
}
// show node info tooltip when clicking node marker
marker.on("click", function(event) {
// close all other popups and tooltips
closeAllTooltips();
closeAllPopups();
// find node
const node = findNodeById(event.target.options.tagName);
if(!node){
return;
}
// show position precision outline
showNodeOutline(node.node_id);
// open tooltip for node
map.openTooltip(getTooltipContentForNode(node), event.target.getLatLng(), {
interactive: true, // allow clicking buttons inside tooltip
permanent: true, // don't auto dismiss when clicking buttons inside tooltip
});
});
// add to cache
nodeMarkers[node.node_id] = marker;
}
window._onNodesUpdated(nodes);
}
function onWaypointsUpdated(updatedWaypoints) {
// clear nodes cache
waypoints = [];
// get config
const now = moment();
const configWaypointsMaxAgeInSeconds = getConfigWaypointsMaxAgeInSeconds();
// add nodes
for(const waypoint of updatedWaypoints){
// skip waypoints older than configured waypoint max age
if(configWaypointsMaxAgeInSeconds){
const lastUpdatedAgeInMillis = now.diff(moment(waypoint.updated_at));
if(lastUpdatedAgeInMillis > configWaypointsMaxAgeInSeconds * 1000){
continue;
}
}
// skip expired waypoints
if(waypoint.expire < Date.now() / 1000){
continue;
}
// skip waypoints without position
if(!waypoint.latitude || !waypoint.longitude){
continue;
}
// fix lat long
waypoint.latitude = waypoint.latitude / 10000000;
waypoint.longitude = waypoint.longitude / 10000000;
// skip waypoints with invalid position
if(!isValidLatLng(waypoint.latitude, waypoint.longitude)){
continue;
}
// wrap longitude for shortest path, everything to left of australia should be shown on the right
var longitude = parseFloat(waypoint.longitude);
if(longitude <= 100){
longitude += 360;
}
// determine emoji to show as marker icon
const emoji = waypoint.icon === 0 ? 128205 : waypoint.icon;
const emojiText = String.fromCodePoint(emoji)
var tooltip = getTooltipContentForWaypoint(waypoint);
// create waypoint marker
const marker = L.marker([waypoint.latitude, longitude], {
icon: L.divIcon({
className: 'waypoint-label',
iconSize: [26, 26], // increase from 12px to 26px
html: emojiText,
}),
}).bindPopup(tooltip).on('click', function(event) {
// close tooltip on click to prevent tooltip and popup showing at same time
event.target.closeTooltip();
});
// show tooltip on desktop only
if(!isMobile()){
marker.bindTooltip(tooltip, {
interactive: true,
});
}
// add marker to waypoints layer groups
marker.addTo(waypointsLayerGroup);
// add to cache
waypoints.push(waypoint);
}
}
function generateConnectionTooltip(connection, nodeA, nodeB, distance) {
let tooltip = `Connection `;
tooltip += ` [${escapeString(nodeA.short_name)}] ${escapeString(nodeA.long_name)} <-> [${escapeString(nodeB.short_name)}] ${escapeString(nodeB.long_name)}`;
tooltip += ` Distance: ${distance}`;
tooltip += ` `;
// Direction A -> B
if (connection.direction_ab.avg_snr_db != null) {
tooltip += `${escapeString(nodeA.short_name)} -> ${escapeString(nodeB.short_name)}: `;
tooltip += ` SNR: ${connection.direction_ab.avg_snr_db.toFixed(1)}dB ${getSignalBarsIndicator(connection.direction_ab.avg_snr_db)} (Average of ${connection.direction_ab.total_count} edges)`;
if (connection.direction_ab.last_5_edges.length > 0) {
tooltip += ` Last 5 edges:`;
for (const edge of connection.direction_ab.last_5_edges) {
const timeAgo = moment(new Date(edge.created_at)).fromNow();
const sourceIcon = edge.source === "TRACEROUTE_APP" ? "⇵" : (edge.source === "NEIGHBORINFO_APP" ? "✳" : "?");
tooltip += ` ${edge.snr_db.toFixed(1)}dB ${getSignalBarsIndicator(edge.snr_db)} (${timeAgo} by:${sourceIcon})`;
}
} else {
tooltip += ` No recent edges`;
}
}
// Direction B -> A
if (connection.direction_ba.avg_snr_db != null) {
tooltip += `${escapeString(nodeB.short_name)} -> ${escapeString(nodeA.short_name)}: `;
tooltip += ` SNR: ${connection.direction_ba.avg_snr_db.toFixed(1)}dB ${getSignalBarsIndicator(connection.direction_ba.avg_snr_db)} (Average of ${connection.direction_ba.total_count} edges)`;
if (connection.direction_ba.last_5_edges.length > 0) {
tooltip += ` Last 5 edges:`;
for (const edge of connection.direction_ba.last_5_edges) {
const timeAgo = moment(new Date(edge.created_at)).fromNow();
const sourceIcon = edge.source === "TRACEROUTE_APP" ? "⇵" : (edge.source === "NEIGHBORINFO_APP" ? "✳" : "?");
tooltip += ` ${edge.snr_db.toFixed(1)}dB ${getSignalBarsIndicator(edge.snr_db)} (${timeAgo} by:${sourceIcon})`;
}
} else {
tooltip += ` No recent edges`;
}
}
// Add terrain profile image
const terrainImageUrl = getTerrainProfileImage(nodeA, nodeB);
tooltip += ` Terrain images from HeyWhatsThat.com `;
tooltip += ` `;
return tooltip;
}
function onConnectionsUpdated(connections) {
// Clear existing connections
clearAllConnections();
for (const connection of connections) {
// Find both node markers
const nodeAMarker = findNodeMarkerById(connection.node_a);
const nodeBMarker = findNodeMarkerById(connection.node_b);
// Skip if either node marker doesn't exist
if (!nodeAMarker || !nodeBMarker) {
continue;
}
// Find node objects for names and terrain profile
const nodeA = findNodeById(connection.node_a);
const nodeB = findNodeById(connection.node_b);
if (!nodeA || !nodeB) {
continue;
}
// Apply bidirectional filter
const configConnectionsBidirectionalOnly = getConfigConnectionsBidirectionalOnly();
if(configConnectionsBidirectionalOnly){
const hasDirectionAB = connection.direction_ab && connection.direction_ab.avg_snr_db != null;
const hasDirectionBA = connection.direction_ba && connection.direction_ba.avg_snr_db != null;
if(!hasDirectionAB || !hasDirectionBA){
continue;
}
}
// Apply minimum SNR filter
const configConnectionsMinSnrDb = getConfigConnectionsMinSnrDb();
if(configConnectionsMinSnrDb != null){
const snrAB = connection.direction_ab && connection.direction_ab.avg_snr_db != null ? connection.direction_ab.avg_snr_db : null;
const snrBA = connection.direction_ba && connection.direction_ba.avg_snr_db != null ? connection.direction_ba.avg_snr_db : null;
const configConnectionsBidirectionalMinSnr = getConfigConnectionsBidirectionalMinSnr();
let hasSnrAboveThreshold;
if(configConnectionsBidirectionalMinSnr){
// Bidirectional mode: ALL existing directions must meet threshold
const directionsToCheck = [];
if(snrAB != null) directionsToCheck.push(snrAB);
if(snrBA != null) directionsToCheck.push(snrBA);
if(directionsToCheck.length === 0){
// No SNR data in either direction, skip
hasSnrAboveThreshold = false;
} else {
// All existing directions must be above threshold
hasSnrAboveThreshold = directionsToCheck.every(snr => snr > configConnectionsMinSnrDb);
}
} else {
// Default mode: EITHER direction has SNR above threshold
hasSnrAboveThreshold = (snrAB != null && snrAB > configConnectionsMinSnrDb) || (snrBA != null && snrBA > configConnectionsMinSnrDb);
}
if(!hasSnrAboveThreshold){
continue;
}
}
// Calculate distance between nodes
const distanceInMeters = nodeAMarker.getLatLng().distanceTo(nodeBMarker.getLatLng()).toFixed(2);
// Apply distance filter
const configConnectionsMaxDistanceInMeters = getConfigConnectionsMaxDistanceInMeters();
if(configConnectionsMaxDistanceInMeters != null && parseFloat(distanceInMeters) > configConnectionsMaxDistanceInMeters){
continue;
}
let distance = `${distanceInMeters} meters`;
if (distanceInMeters >= 1000) {
const distanceInKilometers = (distanceInMeters / 1000).toFixed(2);
distance = `${distanceInKilometers} kilometers`;
}
// Determine line color based on worst average SNR (if colored lines enabled)
const configConnectionsColoredLines = getConfigConnectionsColoredLines();
const worstSnrDb = connection.worst_avg_snr_db;
const lineColor = configConnectionsColoredLines && worstSnrDb != null ? getColourForSnr(worstSnrDb) : '#2563eb';
// Create polyline (bidirectional, no arrows)
const line = L.polyline([
nodeAMarker.getLatLng(),
nodeBMarker.getLatLng(),
], {
color: lineColor,
opacity: 0.75,
weight: 3,
}).addTo(connectionsLayerGroup);
// Generate tooltip
const tooltip = generateConnectionTooltip(connection, nodeA, nodeB, distance);
// Bind tooltip and popup
line.bindTooltip(tooltip, {
sticky: true,
opacity: 1,
interactive: true,
})
.bindPopup(tooltip)
.on('click', function(event) {
// close tooltip on click to prevent tooltip and popup showing at same time
event.target.closeTooltip();
});
// If both nodes are backbone nodes, also add to backbone layer group
if (nodeA.is_backbone && nodeB.is_backbone) {
const backboneLine = L.polyline([
nodeAMarker.getLatLng(),
nodeBMarker.getLatLng(),
], {
color: lineColor,
opacity: 0.75,
weight: 3,
}).addTo(backboneConnectionsLayerGroup);
backboneLine.bindTooltip(tooltip, {
sticky: true,
opacity: 1,
interactive: true,
})
.bindPopup(tooltip)
.on('click', function(event) {
event.target.closeTooltip();
});
}
}
}
function onPositionHistoryUpdated(updatedPositionHistories) {
let positionHistoryLinesCords = [];
// add nodes
for(const positionHistory of updatedPositionHistories) {
// skip position history without position
if(!positionHistory.latitude || !positionHistory.longitude){
continue;
}
// find node this position is for
const node = findNodeById(positionHistory.node_id);
if(!node){
continue;
}
// fix lat long
positionHistory.latitude = positionHistory.latitude / 10000000;
positionHistory.longitude = positionHistory.longitude / 10000000;
// skip position history with invalid position
if(!isValidLatLng(positionHistory.latitude, positionHistory.longitude)){
continue;
}
// wrap longitude for shortest path, everything to left of australia should be shown on the right
var longitude = parseFloat(positionHistory.longitude);
if(longitude <= 100){
longitude += 360;
}
positionHistoryLinesCords.push([positionHistory.latitude, longitude]);
let tooltip = "";
if(positionHistory.type === "position"){
tooltip += `Position `;
} else if(positionHistory.type === "map_report"){
tooltip += `Map Report `;
}
tooltip += ` [${escapeString(node.short_name)}] ${escapeString(node.long_name)}`;
tooltip += ` ${positionHistory.latitude}, ${positionHistory.longitude}`;
tooltip += ` Heard on: ${moment(new Date(positionHistory.created_at)).format("YYYY-MM-DD HH:mm")}`;
// add gateway info if available
if(positionHistory.gateway_id){
const gatewayNode = findNodeById(positionHistory.gateway_id);
const gatewayNodeInfo = gatewayNode ? `[${gatewayNode.short_name}] ${gatewayNode.long_name}` : "???";
tooltip += ` Heard by: ${gatewayNodeInfo} `;
}
// create position history marker
const marker = L.marker([positionHistory.latitude, longitude],{
icon: iconPositionHistory,
}).bindTooltip(tooltip).bindPopup(tooltip).on('click', function(event) {
// close tooltip on click to prevent tooltip and popup showing at same time
event.target.closeTooltip();
});
// add marker to position history layer group
marker.addTo(nodePositionHistoryLayerGroup);
}
// show lines between position history markers
L.polyline(positionHistoryLinesCords, {
color: "#a855f7",
opacity: 1,
}).addTo(nodePositionHistoryLayerGroup);
}
function cleanUpPositionHistory() {
// close tooltips and popups
closeAllPopups();
closeAllTooltips();
// setup node position history layer
nodePositionHistoryLayerGroup.clearLayers();
nodePositionHistoryLayerGroup.removeFrom(map);
nodePositionHistoryLayerGroup.addTo(map);
}
function setLoading(loading){
var reloadButton = document.getElementById("reload-button");
if(loading){
reloadButton.classList.add("animate-spin");
} else {
reloadButton.classList.remove("animate-spin");
}
}
async function reload(goToNodeId, zoom) {
// show loading
setLoading(true);
// clear previous data
clearMap();
// fetch nodes
await window.axios.get('/api/v1/nodes').then(async (response) => {
// update nodes
onNodesUpdated(response.data.nodes);
// hide loading
setLoading(false);
// go to node id if provided
if(goToNodeId){
// go to node
if(window.goToNode(goToNodeId, false, zoom)){
return;
}
// fallback to showing node details since we can't go to the node
window.showNodeDetails(goToNodeId);
}
});
// fetch waypoints (after awaiting nodes, so we can use nodes cache in waypoint tooltips)
await window.axios.get('/api/v1/waypoints').then(async (response) => {
onWaypointsUpdated(response.data.waypoints);
});
// fetch connections (edges)
const connectionsTimePeriodSec = getConfigConnectionsTimePeriodInSeconds();
const connectionsTimeFrom = connectionsTimePeriodSec ? (Date.now() - connectionsTimePeriodSec * 1000) : undefined;
const connectionsParams = new URLSearchParams();
if (connectionsTimeFrom) connectionsParams.set('time_from', connectionsTimeFrom);
await window.axios.get(`/api/v1/connections?${connectionsParams.toString()}`).then(async (response) => {
onConnectionsUpdated(response.data.connections ?? []);
}).catch(() => {
onConnectionsUpdated([]);
});
}
function getRegionFrequencyRange(regionName) {
// determine lora frequency range based on region_name
// https://github.com/meshtastic/firmware/blob/a4c22321fca6fc8da7bab157c3812055603512ba/src/mesh/RadioInterface.cpp#L21
const regionNameToLoraFrequencyRange = {
"US": "902-928 MHz",
"EU_433": "433-434 MHz",
"EU_868": "869.4-869.65 MHz",
"CN": "470-510 MHz",
"JP": "920.8-927.8 MHz",
"ANZ": "915-928 MHz",
"RU": "868.7-869.2 MHz",
"KR": "920-923 MHz",
"TW": "920-925 MHz",
"IN": "865-867 MHz",
"NZ_865": "864-868 MHz",
"TH": "920-925 MHz",
"UA_433": "433-434.7 MHz",
"UA_868": "868-868.6 MHz",
"MY_433": "433-435 MHz",
"MY_919": "919-924 MHz",
"SG_923": "917-925 MHz",
"LORA_24": "2.4-2.4835 GHz",
"UNSET": "902-928 MHz",
}
return regionNameToLoraFrequencyRange[regionName] ?? null;
}
function getPositionPrecisionInMeters(positionPrecision) {
switch(positionPrecision){
case 2: return 5976446;
case 3: return 2988223;
case 4: return 1494111;
case 5: return 747055;
case 6: return 373527;
case 7: return 186763;
case 8: return 93381;
case 9: return 46690;
case 10: return 23345;
case 11: return 11672; // Android LOW_PRECISION
case 12: return 5836;
case 13: return 2918;
case 14: return 1459;
case 15: return 729;
case 16: return 364; // Android MED_PRECISION
case 17: return 182;
case 18: return 91;
case 19: return 45;
case 20: return 22;
case 21: return 11;
case 22: return 5;
case 23: return 2;
case 24: return 1;
case 32: return 0; // Android HIGH_PRECISION
}
return null;
}
function formatPositionPrecision(positionPrecision) {
// get position precision in meters
const positionPrecisionInMeters = getPositionPrecisionInMeters(positionPrecision);
if(positionPrecisionInMeters == null){
return "?";
}
// format kilometers
if(positionPrecisionInMeters > 1000){
const positionPrecisionInKilometers = Math.ceil(positionPrecisionInMeters / 1000);
return `±${positionPrecisionInKilometers}km`;
}
// format meters
return `±${positionPrecisionInMeters}m`;
}
function getTooltipContentForNode(node) {
var loraFrequencyRange = getRegionFrequencyRange(node.region_name);
var tooltip = ` ` +
`${escapeString(node.long_name)} ` +
` Short Name: ${escapeString(node.short_name)}` +
(node.num_online_local_nodes != null ? ` Local Nodes Online: ${node.num_online_local_nodes}` : '') +
(node.position_precision != null && node.position_precision !== 32 ? ` Position Precision: ${formatPositionPrecision(node.position_precision)}` : '') +
` Role: ${node.role_name}` +
` Hardware: ${node.hardware_model_name}` +
(node.firmware_version != null ? ` Firmware: ${node.firmware_version}` : '') +
` OK to MQTT: ${node.ok_to_mqtt}`;
if(node.battery_level){
if(node.battery_level > 100){
tooltip += ` Battery: ${node.battery_level > 100 ? 'Plugged In' : node.battery_level}`;
} else {
tooltip += ` Battery: ${node.battery_level}%`;
}
}
if(node.voltage){
tooltip += ` Voltage: ${Number(node.voltage).toFixed(2)}V`;
}
if(node.channel_utilization){
tooltip += ` Ch Util: ${Number(node.channel_utilization).toFixed(2)}%`;
}
if(node.air_util_tx){
tooltip += ` Air Util: ${Number(node.air_util_tx).toFixed(2)}%`;
}
// ignore alt above 42949000 due to https://github.com/meshtastic/firmware/issues/3109
if(node.altitude && node.altitude < 42949000){
tooltip += ` Altitude: ${node.altitude}m`;
}
// bottom info
tooltip += ` ID: ${node.node_id}`;
tooltip += ` Hex ID: ${node.node_id_hex}`;
tooltip += ` Updated: ${moment(new Date(node.updated_at)).fromNow()}`;
tooltip += (node.mqtt_connection_state_updated_at ? ` MQTT Updated: ${moment(new Date(node.mqtt_connection_state_updated_at)).fromNow()}` : '');
tooltip += (node.neighbours_updated_at ? ` Neighbours Updated: ${moment(new Date(node.neighbours_updated_at)).fromNow()}` : '');
tooltip += (node.position_updated_at ? ` Position Updated: ${moment(new Date(node.position_updated_at)).fromNow()}` : '');
// show details button
tooltip += `Show Full Details `;
tooltip += `Show Connections `;
tooltip += ``;
return tooltip;
}
function getTooltipContentForWaypoint(waypoint) {
// get from node name
var fromNode = findNodeById(waypoint.from);
var tooltip = `${escapeString(waypoint.name)} ` +
(waypoint.description ? ` ${escapeString(waypoint.description)}` : '') +
` Expires: ${moment(new Date(waypoint.expire * 1000)).fromNow()}` +
` Lat/Lng: ${waypoint.latitude}, ${waypoint.longitude}` +
` From ID: ${waypoint.from}` +
` From Hex ID: !${Number(waypoint.from).toString(16)}`;
// show node name this waypoint is from, if possible
if(fromNode != null){
tooltip += ` From Node: ${escapeString(fromNode.long_name) || 'Unnamed Node'} `;
} else {
tooltip += ` From Node: ???`;
}
// bottom info
tooltip += ` ID: ${waypoint.waypoint_id}`;
tooltip += ` Updated: ${moment(new Date(waypoint.updated_at)).fromNow()}`;
return tooltip;
}
window._onHideNodeConnectionsClick = function() {
cleanUpNodeConnections();
};
// parse url params
var queryParams = new URLSearchParams(location.search);
var queryNodeId = queryParams.get('node_id');
var queryLat = queryParams.get('lat');
var queryLng = queryParams.get('lng');
var queryZoom = queryParams.get('zoom');
// go to lat/lng if provided
if(queryLat && queryLng){
const zoomLevel = queryZoom || getConfigZoomLevelGoToNode();
map.flyTo([queryLat, queryLng], zoomLevel, {
animate: false,
});
}
// auto update url when lat/lng/zoom changes
map.on("moveend zoomend", function() {
// check if user enabled auto updating position in url
const autoUpdatePositionInUrl = getConfigAutoUpdatePositionInUrl();
if(!autoUpdatePositionInUrl){
return;
}
// get map info
const latLng = map.getCenter();
const zoom = map.getZoom();
// construct new url
const url = new URL(window.location.href);
url.searchParams.set("lat", latLng.lat);
url.searchParams.set("lng", latLng.lng);
url.searchParams.set("zoom", zoom);
// update current url
if(window.history.replaceState){
window.history.replaceState(null, null, url.toString());
}
});
// reload and go to provided node id
reload(queryNodeId, queryZoom);
// WebSocket connection for real-time messages
var ws = null;
var tracerouteCooldown = {}; // Track last traceroute time per from node (for 20s cooldown)
var activeTracerouteKeys = new Set(); // Track active traceroute visualizations to prevent duplicates
var tracerouteLines = {}; // Track lines for each traceroute route key for cleanup
function connectWebSocket() {
// Determine WebSocket URL - use same hostname as current page, port 8081
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// Heuristic: if running on localhost, use port 8081; otherwise use /ws path via Nginx
const isLocalhost = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
const wsUrl = isLocalhost
? `${wsProtocol}//${location.hostname}:8081`
: `${wsProtocol}//${location.host}/ws`;
console.log('Connecting to WebSocket:', wsUrl);
ws = new WebSocket(wsUrl);
ws.onopen = function() {
console.log('WebSocket connected');
};
ws.onmessage = function(event) {
try {
const message = JSON.parse(event.data);
handleWebSocketMessage(message);
} catch (e) {
console.error('Error parsing WebSocket message:', e);
}
};
ws.onerror = function(error) {
console.error('WebSocket error:', error);
};
ws.onclose = function() {
console.log('WebSocket disconnected, reconnecting in 5 seconds...');
setTimeout(connectWebSocket, 5000);
};
}
function handleWebSocketMessage(message) {
if (message.type === 'traceroute') {
handleTraceroute(message.data);
}
}
function handleTraceroute(data) {
// Only visualize traceroutes where want_response is false (the reply coming back)
if (data.want_response) {
return;
}
// When want_response is false, from and to are swapped from the original request
// The path goes from 'to' (original sender) through route to 'from' (original destination)
const originalSender = data.to; // This was the original sender
const originalDestination = data.from; // This was the original destination
const route = data.route || [];
const snrTowards = data.snr_towards || [];
// Deduplicate: ignore traceroutes from the same original sender for 20 seconds
const now = Date.now();
if (tracerouteCooldown[originalSender] && (now - tracerouteCooldown[originalSender]) < 20000) {
return; // Still in cooldown period
}
// Create unique key for this traceroute path to prevent duplicate visualizations
// Use original sender (to), original destination (from), and route to create unique key
// (ignoring gateway_id since multiple gateways can receive same route)
const routeKey = `${originalSender}-${originalDestination}-${route.join('-')}`;
if (activeTracerouteKeys.has(routeKey)) {
return; // Already visualizing this route
}
// Mark as active and set cooldown
activeTracerouteKeys.add(routeKey);
tracerouteCooldown[originalSender] = now;
// Build the complete path: to (original sender) -> route[0] -> route[1] -> ... -> from (original destination)
const path = [originalSender]; // Start from original sender
if (route.length > 0) {
path.push(...route);
}
path.push(originalDestination); // End at original destination
// Visualize the traceroute with animated hops
visualizeTraceroute(path, snrTowards, routeKey);
}
function visualizeTraceroute(path, snrTowards, routeKey) {
// Verify all nodes in path exist on map
const pathMarkers = [];
for (const nodeId of path) {
const marker = findNodeMarkerById(nodeId);
if (!marker) {
// Node not on map, skip this traceroute
activeTracerouteKeys.delete(routeKey);
return;
}
pathMarkers.push(marker);
}
// Store lines and overlays for this route key for cleanup
const routeElements = {
lines: [],
startOverlay: null,
endOverlay: null,
};
tracerouteLines[routeKey] = routeElements;
// Color starting node (first in path) green and destination node (last in path) red
const startMarker = pathMarkers[0];
const endMarker = pathMarkers[pathMarkers.length - 1];
const startOverlay = L.marker(startMarker.getLatLng(), {
icon: iconTracerouteStart,
zIndexOffset: 10000, // Ensure it's on top
}).addTo(traceroutesLayerGroup);
const endOverlay = L.marker(endMarker.getLatLng(), {
icon: iconTracerouteEnd,
zIndexOffset: 10000, // Ensure it's on top
}).addTo(traceroutesLayerGroup);
// Store overlays for cleanup
routeElements.startOverlay = startOverlay;
routeElements.endOverlay = endOverlay;
// Animate each hop sequentially
let hopIndex = 0;
const animateNextHop = () => {
if (hopIndex >= pathMarkers.length - 1) {
// All hops animated, cleanup after delay
setTimeout(() => {
if (tracerouteLines[routeKey]) {
const routeElements = tracerouteLines[routeKey];
// Remove all lines
if (routeElements.lines) {
routeElements.lines.forEach(line => {
line.remove();
});
}
// Remove node overlays
if (routeElements.startOverlay) {
routeElements.startOverlay.remove();
}
if (routeElements.endOverlay) {
routeElements.endOverlay.remove();
}
delete tracerouteLines[routeKey];
}
activeTracerouteKeys.delete(routeKey);
}, 2500);
return;
}
const fromMarker = pathMarkers[hopIndex];
const toMarker = pathMarkers[hopIndex + 1];
const snr = hopIndex < snrTowards.length ? snrTowards[hopIndex] : null;
// Use orange color for all traceroute lines
const lineColor = '#f97316'; // orange
// Create animated polyline for this hop with orange dotted style
const line = L.polyline([fromMarker.getLatLng(), toMarker.getLatLng()], {
color: lineColor,
weight: 4,
opacity: 0, // Start invisible
// dashArray: '10, 5', // Dotted line style
zIndexOffset: 10000,
}).addTo(traceroutesLayerGroup);
// Fade in animation
line.setStyle({ opacity: 1.0 });
tracerouteLines[routeKey].lines.push(line);
// Animate next hop after 700ms delay
hopIndex++;
setTimeout(animateNextHop, 700);
};
// Start animation
animateNextHop();
}
// Connect WebSocket when page loads
connectWebSocket();