// 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
` + `
ShortSlow
` //+ `
MediumFast
` + `
LongFast
` + `
Offline Too Long
` + `
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 += `

`; tooltip += `
`; 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();