From 8c54e71efc585615957eb68760b42782742fc402 Mon Sep 17 00:00:00 2001 From: Alexander Heinke Date: Sat, 21 Feb 2026 10:38:46 +0100 Subject: [PATCH] Move JavaScript to separate files --- src/public/assets/js/app.js | 956 +++++++++++ src/public/assets/js/config.js | 199 +++ src/public/assets/js/map.js | 1692 +++++++++++++++++++ src/public/index.html | 2862 +------------------------------- 4 files changed, 2850 insertions(+), 2859 deletions(-) create mode 100644 src/public/assets/js/app.js create mode 100644 src/public/assets/js/config.js create mode 100644 src/public/assets/js/map.js diff --git a/src/public/assets/js/app.js b/src/public/assets/js/app.js new file mode 100644 index 0000000..86b4542 --- /dev/null +++ b/src/public/assets/js/app.js @@ -0,0 +1,956 @@ +Vue.createApp({ + data() { + return { + + isShowingAnnouncement: this.shouldShowAnnouncement(), + + configNodesMaxAgeInSeconds: window.getConfigNodesMaxAgeInSeconds(), + configNodesOfflineAgeInSeconds: window.getConfigNodesOfflineAgeInSeconds(), + configWaypointsMaxAgeInSeconds: window.getConfigWaypointsMaxAgeInSeconds(), + configConnectionsMaxDistanceInMeters: window.getConfigConnectionsMaxDistanceInMeters(), + configZoomLevelGoToNode: window.getConfigZoomLevelGoToNode(), + configAutoUpdatePositionInUrl: window.getConfigAutoUpdatePositionInUrl(), + configEnableMapAnimations: window.getConfigEnableMapAnimations(), + configTemperatureFormat: window.getConfigTemperatureFormat(), + configConnectionsTimePeriodInSeconds: window.getConfigConnectionsTimePeriodInSeconds(), + configConnectionsColoredLines: window.getConfigConnectionsColoredLines(), + configConnectionsBidirectionalOnly: window.getConfigConnectionsBidirectionalOnly(), + configConnectionsMinSnrDb: window.getConfigConnectionsMinSnrDb(), + configConnectionsBidirectionalMinSnr: window.getConfigConnectionsBidirectionalMinSnr(), + + isShowingHardwareModels: false, + hardwareModelStats: null, + + isShowingInfoModal: this.shouldShowInfoModal(), + isShowingMobileSearch: false, + isShowingSettings: false, + + nodes: [], + searchText: "", + + selectedNode: null, + selectedNodeDeviceMetrics: [], + selectedNodeEnvironmentMetrics: [], + selectedNodePowerMetrics: [], + selectedNodeMqttMetrics: [], + selectedNodeTraceroutes: [], + + deviceMetricsTimeRange: "7d", + environmentMetricsTimeRange: "7d", + powerMetricsTimeRange: "7d", + + isPositionHistoryModalExpanded: true, + positionHistoryDateTimeFrom: null, + positionHistoryDateTimeTo: null, + selectedNodePositionHistory: [], + selectedNodeToShowPositionHistory: null, + selectedNodePositionHistoryMarkers: [], + selectedNodePositionHistoryPolyLines: [], + + selectedTraceRoute: null, + tracerouteEdges: [], + + selectedNodeToShowConnections: null, + + moment: window.moment, + + }; + }, + mounted: function() { + + // load data + this.loadHardwareModelStats(); + + // handle map click callback from outside of vue + window._onMapClick = () => { + this.searchText = ""; + this.isShowingMobileSearch = false; + }; + + // handle node callback from outside of vue + window._onNodeClick = (node) => { + this.selectedNode = node; + this.loadNodeDeviceMetrics(node.node_id); + this.loadNodeEnvironmentMetrics(node.node_id); + this.loadNodePowerMetrics(node.node_id); + this.loadNodeMqttMetrics(node.node_id); + this.loadNodeTraceroutes(node.node_id); + //this.loadNodePositionHistory(node.node_id); + }; + + // handle node callback from outside of vue + window._onShowNodeConnectionsClick = (node) => { + this.selectedNodeToShowConnections = node; + }; + + // handle nodes updated callback from outside of vue + window._onNodesUpdated = (nodes) => { + this.nodes = nodes; + }; + + }, + methods: { + getAnnouncementId: function() { + // change this when making a new announcement + return "1"; + }, + shouldShowAnnouncement: function() { + const lastSeenAnnouncementId = window.localStorage.getItem("last-seen-announcement-id"); + return lastSeenAnnouncementId?.toString() !== this.getAnnouncementId(); + }, + dismissAnnouncement: function() { + window.localStorage.setItem("last-seen-announcement-id", this.getAnnouncementId()); + this.isShowingAnnouncement = false; + }, + shouldShowInfoModal: function() { + return !window.getConfigHasSeenInfoModal() + && !window.isMobile(); + }, + loadHardwareModelStats: function() { + window.axios.get('/api/v1/stats/hardware-models').then((response) => { + this.hardwareModelStats = response.data.hardware_model_stats; + }).catch((error) => { + // do nothing + }); + }, + loadNodeDeviceMetrics: function(nodeId) { + + // calculate unix timestamps in milliseconds for supported time ranges + const oneDayAgoInMilliseconds = new Date().getTime() - (86400 * 1000); + const threeDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000); + const sevenDaysAgoInMilliseconds = new Date().getTime() - (604800 * 1000); + const thirtyDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000 * 10); + + // determine how long back to load device metrics from + var timeFrom = threeDaysAgoInMilliseconds; + switch(this.deviceMetricsTimeRange){ + case "1d": { + timeFrom = oneDayAgoInMilliseconds; + break; + } + case "3d": { + timeFrom = threeDaysAgoInMilliseconds; + break; + } + case "7d": { + timeFrom = sevenDaysAgoInMilliseconds; + break; + } + case "30d": { + timeFrom = thirtyDaysAgoInMilliseconds; + break; + } + } + + window.axios.get(`/api/v1/nodes/${nodeId}/device-metrics`, { + params: { + time_from: timeFrom, + }, + }).then((response) => { + // reverse response, as it's newest to oldest, but we want oldest to newest + this.selectedNodeDeviceMetrics = response.data.device_metrics.reverse(); + this.renderDeviceMetricCharts(); + }).catch(() => { + this.selectedNodeDeviceMetrics = []; + this.renderDeviceMetricCharts(); + }); + }, + loadNodeEnvironmentMetrics: function(nodeId) { + + // calculate unix timestamps in milliseconds for supported time ranges + const oneDayAgoInMilliseconds = new Date().getTime() - (86400 * 1000); + const threeDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000); + const sevenDaysAgoInMilliseconds = new Date().getTime() - (604800 * 1000); + const thirtyDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000 * 10); + + // determine how long back to load environment metrics from + var timeFrom = threeDaysAgoInMilliseconds; + switch(this.environmentMetricsTimeRange){ + case "1d": { + timeFrom = oneDayAgoInMilliseconds; + break; + } + case "3d": { + timeFrom = threeDaysAgoInMilliseconds; + break; + } + case "7d": { + timeFrom = sevenDaysAgoInMilliseconds; + break; + } + case "30d": { + timeFrom = thirtyDaysAgoInMilliseconds; + break; + } + } + + window.axios.get(`/api/v1/nodes/${nodeId}/environment-metrics`, { + params: { + time_from: timeFrom, + }, + }).then((response) => { + // reverse response, as it's newest to oldest, but we want oldest to newest + this.selectedNodeEnvironmentMetrics = response.data.environment_metrics.reverse(); + this.renderEnvironmentMetricCharts(); + }).catch(() => { + this.selectedNodeEnvironmentMetrics = []; + this.renderEnvironmentMetricCharts(); + }); + }, + loadNodePowerMetrics: function(nodeId) { + + // calculate unix timestamps in milliseconds for supported time ranges + const oneDayAgoInMilliseconds = new Date().getTime() - (86400 * 1000); + const threeDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000); + const sevenDaysAgoInMilliseconds = new Date().getTime() - (604800 * 1000); + const thirtyDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000 * 10); + + // determine how long back to load power metrics from + var timeFrom = threeDaysAgoInMilliseconds; + switch(this.powerMetricsTimeRange){ + case "1d": { + timeFrom = oneDayAgoInMilliseconds; + break; + } + case "3d": { + timeFrom = threeDaysAgoInMilliseconds; + break; + } + case "7d": { + timeFrom = sevenDaysAgoInMilliseconds; + break; + } + case "30d": { + timeFrom = thirtyDaysAgoInMilliseconds; + break; + } + } + + window.axios.get(`/api/v1/nodes/${nodeId}/power-metrics`, { + params: { + time_from: timeFrom, + }, + }).then((response) => { + // reverse response, as it's newest to oldest, but we want oldest to newest + this.selectedNodePowerMetrics = response.data.power_metrics.reverse(); + this.renderPowerMetricCharts(); + }).catch(() => { + this.selectedNodePowerMetrics = []; + this.renderPowerMetricCharts(); + }); + }, + loadNodeMqttMetrics: function(nodeId) { + this.selectedNodeMqttMetrics = []; + window.axios.get(`/api/v1/nodes/${nodeId}/mqtt-metrics`).then((response) => { + this.selectedNodeMqttMetrics = response.data.mqtt_metrics; + }).catch(() => { + // do nothing + }); + }, + loadNodeTraceroutes: function(nodeId) { + this.selectedNodeTraceroutes = []; + window.axios.get(`/api/v1/nodes/${nodeId}/traceroutes`, { + params: { + count: 5, + }, + }).then((response) => { + this.selectedNodeTraceroutes = response.data.traceroutes; + }).catch(() => { + // do nothing + }); + }, + loadNodePositionHistory: function(nodeId) { + this.selectedNodePositionHistory = []; + window.axios.get(`/api/v1/nodes/${nodeId}/position-history`, { + params: { + // parse from datetime-local format, and send as unix timestamp in milliseconds + time_from: moment(this.positionHistoryDateTimeFrom, "YYYY-MM-DDTHH:mm").format("x"), + time_to: moment(this.positionHistoryDateTimeTo, "YYYY-MM-DDTHH:mm").format("x"), + }, + }).then((response) => { + this.selectedNodePositionHistory = response.data.position_history; + if(this.selectedNodeToShowPositionHistory != null){ + clearAllPositionHistory(); + onPositionHistoryUpdated(response.data.position_history); + } + + }).catch(() => { + // do nothing + }); + }, + renderDeviceMetricCharts: function() { + try { + this.updateDeviceMetricsChart(); + } catch(e) { + console.log(e); + } + }, + updateDeviceMetricsChart: function() { + + // destroy existing chart + const chartElementId = "deviceMetricsChart"; + const existingChart = window.Chart.getChart(chartElementId); + if(existingChart != null){ + existingChart.destroy(); + } + + // get chart element + const chartElement = window.document.getElementById(chartElementId); + if(!chartElement){ + return; + } + + // create chart data + const labels = []; + const batteryMetrics = []; + const channelUtilizationMetrics = []; + const airUtilTxMetrics = []; + for(const deviceMetric of this.selectedNodeDeviceMetrics){ + labels.push(moment(deviceMetric.created_at)); + batteryMetrics.push(deviceMetric.battery_level); + channelUtilizationMetrics.push(deviceMetric.channel_utilization); + airUtilTxMetrics.push(deviceMetric.air_util_tx); + } + + // create chart + new window.Chart(chartElement, { + type: 'line', + data: { + labels: labels, + datasets: [ + { + label: 'Battery Level', + borderColor: '#3b82f6', + backgroundColor: '#3b82f6', + pointStyle: false, // no points + fill: false, + data: batteryMetrics, + }, + { + label: 'Channel Util', + borderColor: '#22c55e', + backgroundColor: '#22c55e', + showLine: false, // no lines between points + fill: false, + data: channelUtilizationMetrics, + }, + { + label: 'Air Util TX', + borderColor: '#f97316', + backgroundColor: '#f97316', + showLine: false, // no lines between points + fill: false, + data: airUtilTxMetrics, + + }, + ], + }, + options: { + responsive: true, + borderWidth: 2, + elements: { + point: { + radius: 2, + }, + }, + scales: { + x: { + position: 'top', + type: 'time', + time: { + unit: 'day', + displayFormats: { + day: 'MMM DD', // Jan 01 + }, + }, + }, + y: { + min: 0, + max: 101, // 101 is "Plugged In", need to include for tooltip to work + ticks: { + callback: (label) => `${label}%`, + }, + }, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + mode: "index", + intersect: false, + callbacks: { + label: (item) => { + return `${item.dataset.label}: ${item.formattedValue}%`; + }, + }, + }, + }, + } + }); + + }, + renderEnvironmentMetricCharts: function() { + try { + this.updateEnvironmentMetricsChart(); + } catch(e) { + console.log(e); + } + }, + updateEnvironmentMetricsChart: function() { + + // destroy existing chart + const chartElementId = "environmentMetricsChart"; + const existingChart = window.Chart.getChart(chartElementId); + if(existingChart != null){ + existingChart.destroy(); + } + + // get chart element + const chartElement = window.document.getElementById(chartElementId); + if(!chartElement){ + return; + } + + // create chart data + const labels = []; + const temperatureMetrics = []; + const relativeHumidityMetrics = []; + const barometricPressureMetrics = []; + const iaqMetrics = []; + for(const deviceMetric of this.selectedNodeEnvironmentMetrics){ + labels.push(moment(deviceMetric.created_at)); + temperatureMetrics.push(deviceMetric.temperature); + relativeHumidityMetrics.push(deviceMetric.relative_humidity); + barometricPressureMetrics.push(deviceMetric.barometric_pressure); + iaqMetrics.push(deviceMetric.iaq); + } + + // create chart + new window.Chart(chartElement, { + type: 'line', + data: { + labels: labels, + datasets: [ + { + label: 'Temperature', + suffix: '°C', + borderColor: '#3b82f6', + backgroundColor: '#3b82f6', + pointStyle: false, // no points + fill: false, + data: temperatureMetrics, + yAxisID: 'y', + }, + { + label: 'Humidity', + suffix: '%', + borderColor: '#22c55e', + backgroundColor: '#22c55e', + pointStyle: false, // no points + fill: false, + data: relativeHumidityMetrics, + yAxisID: 'y', + }, + { + label: 'Pressure', + suffix: 'hPa', + borderColor: '#f97316', + backgroundColor: '#f97316', + pointStyle: false, // no points + fill: false, + data: barometricPressureMetrics, + yAxisID: 'y1', + + }, + { + label: 'IAQ', + suffix: 'IAQ', + borderColor: '#f472b6', + backgroundColor: '#f472b6', + pointStyle: false, // no points + fill: false, + data: iaqMetrics, + yAxisID: 'yIAQ', + + }, + ], + }, + options: { + responsive: true, + borderWidth: 2, + spanGaps: 1000 * 60 * 60 * 24, // only show lines between metrics with a 24 hour or less gap + elements: { + point: { + radius: 2, + }, + }, + scales: { + x: { + position: 'top', + type: 'time', + time: { + unit: 'day', + displayFormats: { + day: 'MMM DD', // Jan 01 + }, + }, + }, + y: { + min: -20, + max: 100, + }, + y1: { + min: 800, + max: 1100, + ticks: { + stepSize: 10, + callback: (label) => `${label} hPa`, + }, + position: 'right', + grid: { + drawOnChartArea: false, // only want the grid lines for one axis to show up + }, + }, + yIAQ: { + type: 'linear', + display: false, + }, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + mode: "index", + intersect: false, + callbacks: { + label: (item) => { + return `${item.dataset.label}: ${item.formattedValue}${item.dataset.suffix}`; + }, + }, + }, + }, + } + }); + + }, + renderPowerMetricCharts: function() { + try { + this.updatePowerMetricsChart(); + } catch(e) { + console.log(e); + } + }, + updatePowerMetricsChart: function() { + + // destroy existing chart + const chartElementId = "powerMetricsChart"; + const existingChart = window.Chart.getChart(chartElementId); + if(existingChart != null){ + existingChart.destroy(); + } + + // get chart element + const chartElement = window.document.getElementById(chartElementId); + if(!chartElement){ + return; + } + + // create chart data + const labels = []; + const channel1VoltageReadings = []; + const channel2VoltageReadings = []; + const channel3VoltageReadings = []; + const channel1CurrentReadings = []; + const channel2CurrentReadings = []; + const channel3CurrentReadings = []; + for(const powerMetric of this.selectedNodePowerMetrics){ + labels.push(moment(powerMetric.created_at)); + channel1VoltageReadings.push(powerMetric.ch1_voltage); + channel2VoltageReadings.push(powerMetric.ch2_voltage); + channel3VoltageReadings.push(powerMetric.ch3_voltage); + channel1CurrentReadings.push(powerMetric.ch1_current); + channel2CurrentReadings.push(powerMetric.ch2_current); + channel3CurrentReadings.push(powerMetric.ch3_current); + } + + // create chart + new window.Chart(chartElement, { + type: 'line', + data: { + labels: labels, + datasets: [ + { + label: 'Ch1 Voltage', + suffix: "V", + borderColor: '#3b82f6', + backgroundColor: '#3b82f6', + pointStyle: false, // no points + fill: false, + data: channel1VoltageReadings, + yAxisID: 'y', + }, + { + label: 'Ch2 Voltage', + suffix: "V", + borderColor: '#22c55e', + backgroundColor: '#22c55e', + pointStyle: false, // no points + fill: false, + data: channel2VoltageReadings, + yAxisID: 'y', + }, + { + label: 'Ch3 Voltage', + suffix: "V", + borderColor: '#f97316', + backgroundColor: '#f97316', + pointStyle: false, // no points + fill: false, + data: channel3VoltageReadings, + yAxisID: 'y', + }, + { + label: 'Ch1 Current', + suffix: "mA", + borderColor: '#93c5fd', + backgroundColor: '#93c5fd', + pointStyle: false, // no points + fill: false, + data: channel1CurrentReadings, + yAxisID: 'y1', + }, + { + label: 'Ch2 Current', + suffix: "mA", + borderColor: '#86efac', + backgroundColor: '#86efac', + pointStyle: false, // no points + fill: false, + data: channel2CurrentReadings, + yAxisID: 'y1', + }, + { + label: 'Ch3 Current', + suffix: "mA", + borderColor: '#fdba74', + backgroundColor: '#fdba74', + pointStyle: false, // no points + fill: false, + data: channel3CurrentReadings, + yAxisID: 'y1', + }, + ], + }, + options: { + responsive: true, + borderWidth: 2, + spanGaps: 1000 * 60 * 60 * 3, // only show lines between metrics with a 3 hour or less gap + elements: { + point: { + radius: 2, + }, + }, + scales: { + x: { + position: 'top', + type: 'time', + time: { + unit: 'day', + displayFormats: { + day: 'MMM DD', // Jan 01 + }, + }, + }, + y: { + min: 0, + suggestedMax: 6, + ticks: { + callback: (label) => `${label}V`, + }, + }, + y1: { + suggestedMin: -50, + suggestedMax: 50, + ticks: { + stepSize: 50, + callback: (label) => `${label}mA`, + }, + position: 'right', + grid: { + drawOnChartArea: false, // only want the grid lines for one axis to show up + }, + }, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + mode: "index", + intersect: false, + callbacks: { + label: (item) => { + return `${item.dataset.label}: ${item.formattedValue}${item.dataset.suffix}`; + }, + }, + }, + }, + } + }); + + }, + showTraceRoute: function(traceroute) { + this.selectedTraceRoute = traceroute; + }, + findNodeById: function(id) { + return window.findNodeById(id); + }, + findNodeMarkerById: function(id) { + return window.findNodeMarkerById(id); + }, + onSearchResultNodeClick: function(node) { + + // clear search + this.searchText = ""; + + // hide search + this.isShowingMobileSearch = false; + + // go to node + if(window.goToNode(node.node_id)){ + return; + } + + // fallback to showing node details since we can't go to the node + window.showNodeDetails(node.node_id); + + }, + dismissInfoModal: function() { + this.isShowingInfoModal = false; + window.setConfigHasSeenInfoModal(true); + }, + getRegionFrequencyRange: function(regionName) { + return window.getRegionFrequencyRange(regionName); + }, + showNodePositionHistory: function(nodeId) { + + // find node + const node = findNodeById(nodeId); + if(!node){ + return; + } + + // update ui + this.selectedNode = null; + this.selectedNodeToShowPositionHistory = node; + this.isPositionHistoryModalExpanded = true; + + // close node info tooltip as position history shows under it + window.closeAllTooltips(); + + // reset default time range when opening position history ui + // YYYY-MM-DDTHH:mm is the format expected by the datetime-local input type + this.positionHistoryDateTimeFrom = moment().subtract(1, "hours").format('YYYY-MM-DDTHH:mm'); + this.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm'); + + // load position history + this.loadNodePositionHistory(nodeId); + + }, + onPositionHistoryQuickRangeClick: function(range) { + + // update position history time range + switch(range){ + case "1h": { + this.positionHistoryDateTimeFrom = moment().subtract(1, "hours").format('YYYY-MM-DDTHH:mm'); + this.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm'); + break; + } + case "24h": { + this.positionHistoryDateTimeFrom = moment().subtract(24, "hours").format('YYYY-MM-DDTHH:mm'); + this.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm'); + break; + } + case "7d": { + this.positionHistoryDateTimeFrom = moment().subtract(7, "days").format('YYYY-MM-DDTHH:mm'); + this.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm'); + break; + } + } + + // reload position history + const node = this.selectedNodeToShowPositionHistory; + if(node){ + this.loadNodePositionHistory(node.node_id); + } + + }, + getShareLinkForNode: function(nodeId) { + return window.location.origin + `/?node_id=${nodeId}`; + }, + copyShareLinkForNode: function(nodeId) { + + // make sure copy to clipboard is supported + if(!navigator.clipboard || !navigator.clipboard.writeText){ + alert("Clipboard not supported. Site must be served via https on iOS."); + return; + } + + // copy share link to clipboard + const url = this.getShareLinkForNode(nodeId); + navigator.clipboard.writeText(url); + + // tell user we copied it + alert("Link copied to clipboard!"); + + }, + dismissShowingNodeConnections: function() { + window._onHideNodeConnectionsClick(); + this.selectedNodeToShowConnections = null; + }, + dismissShowingNodePositionHistory: function() { + this.selectedNodePositionHistory = []; + this.selectedNodeToShowPositionHistory = null; + this.selectedNodePositionHistoryMarkers = []; + this.selectedNodePositionHistoryPolyLines = []; + cleanUpPositionHistory(); + }, + formatUptimeSeconds: function(secondsToFormat) { + secondsToFormat = Number(secondsToFormat); + var days = Math.floor(secondsToFormat / (3600 * 24)); + var hours = Math.floor((secondsToFormat % (3600 * 24)) / 3600); + var minutes = Math.floor((secondsToFormat % 3600) / 60); + var seconds = Math.floor(secondsToFormat % 60); + var daysPlural = days === 1 ? 'day' : 'days'; + return `${days} ${daysPlural} ${hours}h ${minutes}m ${seconds}s`; + }, + formatTemperature: function(celsius) { + switch(this.configTemperatureFormat){ + case "celsius": { + return `${Number(celsius).toFixed(0)}°C`; + } + case "fahrenheit": { + const fahrenheit = this.celsiusToFahrenheit(celsius); + return `${fahrenheit.toFixed(0)}°F`; + } + } + }, + convertTemperature: function(celsius) { + switch(this.configTemperatureFormat){ + case "celsius": { + return celsius; + } + case "fahrenheit": { + return this.celsiusToFahrenheit(celsius); + } + } + }, + getTemperatureUnit: function() { + switch(this.configTemperatureFormat){ + case "celsius": return "°C"; + case "fahrenheit": return "°F"; + } + }, + celsiusToFahrenheit: function(celsius) { + return (celsius * 9/5) + 32; + }, + getNodeColour(nodeId) { + // convert node id to a hex colour + return "#" + (nodeId & 0x00FFFFFF).toString(16).padStart(6, '0'); + }, + getNodeTextColour(nodeId) { + + // extract rgb components + const r = (nodeId & 0xFF0000) >> 16; + const g = (nodeId & 0x00FF00) >> 8; + const b = nodeId & 0x0000FF; + + // calculate brightness + const brightness = ((r * 0.299) + (g * 0.587) + (b * 0.114)) / 255; + + // determine text color based on brightness + return brightness > 0.5 ? "#000000" : "#FFFFFF"; + + }, + }, + computed: { + searchedNodes() { + + // search nodes + const nodes = this.nodes.filter((node) => { + const matchesId = node.node_id?.toLowerCase()?.includes(this.searchText.toLowerCase()); + const matchesHexId = node.node_id_hex?.toLowerCase()?.includes(this.searchText.toLowerCase()); + const matchesLongName = node.long_name?.toLowerCase()?.includes(this.searchText.toLowerCase()); + const matchesShortName = node.short_name?.toLowerCase()?.includes(this.searchText.toLowerCase()); + return matchesId || matchesHexId || matchesLongName || matchesShortName; + }); + + // order alphabetically by long name + nodes.sort((nodeA, nodeB) => { + const nodeALongName = nodeA.long_name || ""; + const nodeBLongName = nodeB.long_name || ""; + return nodeALongName.localeCompare(nodeBLongName); + }); + + // only return the first 500 results to avoid ui lag... + return nodes.slice(0, 500); + + }, + selectedNodeLatestPowerMetric() { + const [ latestPowerMetric ] = this.selectedNodePowerMetrics.slice(-1); + return latestPowerMetric; + }, + }, + watch: { + configNodesMaxAgeInSeconds() { + window.setConfigNodesMaxAgeInSeconds(this.configNodesMaxAgeInSeconds); + }, + configNodesOfflineAgeInSeconds() { + window.setConfigNodesOfflineAgeInSeconds(this.configNodesOfflineAgeInSeconds); + }, + configWaypointsMaxAgeInSeconds() { + window.setConfigWaypointsMaxAgeInSeconds(this.configWaypointsMaxAgeInSeconds); + }, + configConnectionsMaxDistanceInMeters() { + window.setConfigConnectionsMaxDistanceInMeters(this.configConnectionsMaxDistanceInMeters); + }, + configZoomLevelGoToNode() { + window.setConfigZoomLevelGoToNode(this.configZoomLevelGoToNode); + }, + configAutoUpdatePositionInUrl() { + window.setConfigAutoUpdatePositionInUrl(this.configAutoUpdatePositionInUrl); + }, + configEnableMapAnimations() { + window.setConfigEnableMapAnimations(this.configEnableMapAnimations); + }, + configTemperatureFormat() { + window.setConfigTemperatureFormat(this.configTemperatureFormat); + }, + configConnectionsTimePeriodInSeconds() { + window.setConfigConnectionsTimePeriodInSeconds(this.configConnectionsTimePeriodInSeconds); + }, + configConnectionsColoredLines() { + window.setConfigConnectionsColoredLines(this.configConnectionsColoredLines); + }, + configConnectionsBidirectionalOnly() { + window.setConfigConnectionsBidirectionalOnly(this.configConnectionsBidirectionalOnly); + }, + configConnectionsMinSnrDb() { + window.setConfigConnectionsMinSnrDb(this.configConnectionsMinSnrDb); + }, + configConnectionsBidirectionalMinSnr() { + window.setConfigConnectionsBidirectionalMinSnr(this.configConnectionsBidirectionalMinSnr); + }, + deviceMetricsTimeRange() { + this.loadNodeDeviceMetrics(this.selectedNode.node_id); + }, + environmentMetricsTimeRange() { + this.loadNodeEnvironmentMetrics(this.selectedNode.node_id); + }, + powerMetricsTimeRange() { + this.loadNodePowerMetrics(this.selectedNode.node_id); + }, + }, +}).mount('#app'); \ No newline at end of file diff --git a/src/public/assets/js/config.js b/src/public/assets/js/config.js new file mode 100644 index 0000000..5866a4c --- /dev/null +++ b/src/public/assets/js/config.js @@ -0,0 +1,199 @@ +function getConfigHasSeenInfoModal() { + return localStorage.getItem("config_has_seen_info_modal") === "true"; +} + +function setConfigHasSeenInfoModal(value) { + return localStorage.setItem("config_has_seen_info_modal", value); +} + +function getConfigAutoUpdatePositionInUrl() { + // use user preference, or enable by default + const value = localStorage.getItem("config_auto_update_position_in_url"); + return value === "true" || value == null; +} + +function setConfigAutoUpdatePositionInUrl(value) { + return localStorage.setItem("config_auto_update_position_in_url", value); +} + +function getConfigEnableMapAnimations() { + + const value = localStorage.getItem("config_enable_map_animations"); + + // enable animations by default + if(value === null){ + return true; + } + + return value === "true"; + +} + +function setConfigEnableMapAnimations(value) { + return localStorage.setItem("config_enable_map_animations", value); +} + +function getConfigTemperatureFormat() { + return localStorage.getItem("config_temperature_format") || "celsius"; +} + +function setConfigTemperatureFormat(format) { + return localStorage.setItem("config_temperature_format", format); +} + +function getConfigMapSelectedTileLayer() { + return localStorage.getItem("config_map_selected_tile_layer") || "Thunderforest Neighbourhood"; +} + +function setConfigMapSelectedTileLayer(layer) { + return localStorage.setItem("config_map_selected_tile_layer", layer); +} + +function getConfigMapEnabledOverlayLayers() { + + try { + const value = localStorage.getItem("config_map_enabled_overlay_layers"); + if(value){ + return JSON.parse(value); + } + } catch(e) {} + + // overlays enabled by default + return ["Legend", "Position History", "Traceroutes"]; + +} + +function setConfigMapEnabledOverlayLayers(layers) { + return localStorage.setItem("config_map_enabled_overlay_layers", JSON.stringify(layers)); +} + +function getConfigNodesMaxAgeInSeconds() { + const value = localStorage.getItem("config_nodes_max_age_in_seconds"); + return value != null ? parseInt(value) : null; +} + +function setConfigNodesMaxAgeInSeconds(value) { + if(value != null){ + return localStorage.setItem("config_nodes_max_age_in_seconds", value); + } else { + return localStorage.removeItem("config_nodes_max_age_in_seconds"); + } +} + +function getConfigNodesOfflineAgeInSeconds() { + const value = localStorage.getItem("config_nodes_offline_age_in_seconds"); + return value != null ? parseInt(value) : 10800; +} + +function setConfigNodesOfflineAgeInSeconds(value) { + if(value != null){ + return localStorage.setItem("config_nodes_offline_age_in_seconds", value); + } else { + return localStorage.removeItem("config_nodes_offline_age_in_seconds"); + } +} + +function getConfigWaypointsMaxAgeInSeconds() { + const value = localStorage.getItem("config_waypoints_max_age_in_seconds"); + return value != null ? parseInt(value) : null; +} + +function setConfigWaypointsMaxAgeInSeconds(value) { + if(value != null){ + return localStorage.setItem("config_waypoints_max_age_in_seconds", value); + } else { + return localStorage.removeItem("config_waypoints_max_age_in_seconds"); + } +} + +function getConfigConnectionsMaxDistanceInMeters() { + const value = localStorage.getItem("config_connections_max_distance_in_meters"); + // default to 70km (70,000 meters) + return value != null ? parseInt(value) : 70000; +} + +function setConfigConnectionsMaxDistanceInMeters(value) { + return localStorage.setItem("config_connections_max_distance_in_meters", value); +} + +function getConfigZoomLevelGoToNode() { + const value = localStorage.getItem("config_zoom_level_go_to_node"); + const parsedValue = value != null ? parseInt(value) : null; + return parsedValue || 15; +} + +function setConfigZoomLevelGoToNode(value) { + return localStorage.setItem("config_zoom_level_go_to_node", value); +} + +function getConfigConnectionsTimePeriodInSeconds() { + const value = localStorage.getItem("config_connections_time_period_in_seconds"); + // default to 7 days if unset + return value != null ? parseInt(value) : 604800; +} + +function setConfigConnectionsTimePeriodInSeconds(value) { + return localStorage.setItem("config_connections_time_period_in_seconds", value); +} + +function getConfigConnectionsColoredLines() { + const value = localStorage.getItem("config_connections_colored_lines"); + // disable colored lines by default + if(value === null){ + return false; + } + return value === "true"; +} + +function setConfigConnectionsColoredLines(value) { + return localStorage.setItem("config_connections_colored_lines", value); +} + +function getConfigConnectionsBidirectionalOnly() { + const value = localStorage.getItem("config_connections_bidirectional_only"); + // disable bidirectional filter by default + if(value === null){ + return false; + } + return value === "true"; +} + +function setConfigConnectionsBidirectionalOnly(value) { + return localStorage.setItem("config_connections_bidirectional_only", value); +} + +function getConfigConnectionsMinSnrDb() { + const value = localStorage.getItem("config_connections_min_snr_db"); + // default to null (unset) + if(value === null || value === ""){ + return null; + } + const parsed = parseFloat(value); + return isNaN(parsed) ? null : parsed; +} + +function setConfigConnectionsMinSnrDb(value) { + if(value === null || value === "" || value === undefined){ + return localStorage.removeItem("config_connections_min_snr_db"); + } + // Convert to string for localStorage (handles both number and string inputs) + const stringValue = typeof value === "number" ? value.toString() : String(value); + return localStorage.setItem("config_connections_min_snr_db", stringValue); +} + +function getConfigConnectionsBidirectionalMinSnr() { + const value = localStorage.getItem("config_connections_bidirectional_min_snr"); + // disable bidirectional minimum SNR by default + if(value === null){ + return false; + } + return value === "true"; +} + +function setConfigConnectionsBidirectionalMinSnr(value) { + return localStorage.setItem("config_connections_bidirectional_min_snr", value); +} + +function isMobile() { + return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); +} \ No newline at end of file diff --git a/src/public/assets/js/map.js b/src/public/assets/js/map.js new file mode 100644 index 0000000..e25c067 --- /dev/null +++ b/src/public/assets/js/map.js @@ -0,0 +1,1692 @@ +// 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(); \ No newline at end of file diff --git a/src/public/index.html b/src/public/index.html index cd307b6..c375114 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -1318,2865 +1318,9 @@ - - - - - + + +