diff --git a/README.md b/README.md index 854146a..2ee653b 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,11 @@ git clone https://github.com/liamcottle/meshtastic-map cd meshtastic-map ``` +Install Meshtastic protobufs definitions +``` +git clone https://github.com/meshtastic/protobufs src/protobufs +``` + Install NodeJS dependencies ``` diff --git a/package-lock.json b/package-lock.json index f5ccdb8..1fb2318 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,6 +69,7 @@ "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -2009,6 +2010,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001737", "electron-to-chromium": "^1.5.211", @@ -3304,10 +3306,9 @@ } }, "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", "dev": true, "license": "ISC", "dependencies": { @@ -5149,6 +5150,7 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/config": "6.16.2", "@prisma/engines": "6.16.2" @@ -5237,9 +5239,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" diff --git a/screenshot.png b/screenshot.png index 13d17b2..25ee627 100644 Binary files a/screenshot.png and b/screenshot.png differ diff --git a/src/public/assets/css/styles.css b/src/public/assets/css/styles.css new file mode 100644 index 0000000..8811cad --- /dev/null +++ b/src/public/assets/css/styles.css @@ -0,0 +1,115 @@ +/* used to prevent ui flicker before vuejs loads */ +[v-cloak] { + display: none; +} + +.icon-longfast { + background-color: #009016; + border-radius: 25px; + border: 1px solid #2C2D3C; +} + +.icon-mediumfast { + background-color: #326be7; + border-radius: 25px; + border: 1px solid #2C2D3C; +} + +.icon-shortslow { + background-color: #0077e6; + border-radius: 25px; + border: 1px solid #2C2D3C; +} + +.icon-mqtt-connected { + background-color: #2563eb; /* Change to use same color as disconnected // #16a34a; */ + border-radius: 25px; + border: 1px solid #2C2D3C; +} + +.icon-mqtt-disconnected { + background-color: #2563eb; + border-radius: 25px; + border: 1px solid #2C2D3C; +} + +.icon-offline { + background-color: #e2286c; + border-radius: 25px; + border: 1px solid #2C2D3C; +} + +.icon-position-history { + background-color: #a855f7; + border-radius: 25px; + border: 1px solid #2C2D3C; +} + +.icon-traceroute-start { + background-color: #16a34a; /* green */ + border-radius: 25px; + border: 1px solid #2C2D3C; +} + +.icon-traceroute-end { + background-color: #dc2626; /* red */ + border-radius: 25px; + border: 1px solid #2C2D3C; +} + +.waypoint-label { + font-size: 26px; + background-color: transparent; +} + +.link { + color: #2563eb; +} + +.link:hover { + text-decoration: underline; +} + +.tooltip { + position: relative; + display: inline-block; +} + +.tooltip .tooltip-text { + visibility: hidden; + width: 80px; + background-color: black; + color: #fff; + text-align: center; + padding: 4px 0; + border-radius: 6px; + position: absolute; + z-index: 10000; + top: 100%; + left: 50%; + margin-top: 8px; + margin-left: -40px; /* Use half of the width (120/2 = 60), to center the tooltip */ +} + +.tooltip .tooltip-text::after { + content: " "; + position: absolute; + bottom: 100%; /* At the top of the tooltip */ + left: 50%; + margin-left: -5px; + border-width: 5px; + border-style: solid; + border-color: transparent transparent black transparent; +} + +.tooltip:hover .tooltip-text { + visibility: visible; +} + +.z-search { + z-index: 1001; +} + +.z-sidebar { + z-index: 1002; +} \ No newline at end of file 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/images/devices/HELTEC_MESH_POCKET.png b/src/public/images/devices/HELTEC_MESH_POCKET.png new file mode 100644 index 0000000..ae7a797 Binary files /dev/null and b/src/public/images/devices/HELTEC_MESH_POCKET.png differ diff --git a/src/public/images/devices/HELTEC_MESH_SOLAR.png b/src/public/images/devices/HELTEC_MESH_SOLAR.png new file mode 100644 index 0000000..a6575ef Binary files /dev/null and b/src/public/images/devices/HELTEC_MESH_SOLAR.png differ diff --git a/src/public/images/devices/HELTEC_V4.png b/src/public/images/devices/HELTEC_V4.png new file mode 100644 index 0000000..6dd4c49 Binary files /dev/null and b/src/public/images/devices/HELTEC_V4.png differ diff --git a/src/public/images/devices/PORTDUINO.png b/src/public/images/devices/PORTDUINO.png new file mode 100644 index 0000000..0ccea0d Binary files /dev/null and b/src/public/images/devices/PORTDUINO.png differ diff --git a/src/public/images/devices/SEEED_SOLAR_NODE.png b/src/public/images/devices/SEEED_SOLAR_NODE.png new file mode 100644 index 0000000..7f894bc Binary files /dev/null and b/src/public/images/devices/SEEED_SOLAR_NODE.png differ diff --git a/src/public/images/devices/SEEED_WIO_TRACKER_L1.png b/src/public/images/devices/SEEED_WIO_TRACKER_L1.png new file mode 100644 index 0000000..e432076 Binary files /dev/null and b/src/public/images/devices/SEEED_WIO_TRACKER_L1.png differ diff --git a/src/public/images/devices/THINKNODE_M1.png b/src/public/images/devices/THINKNODE_M1.png new file mode 100644 index 0000000..7995026 Binary files /dev/null and b/src/public/images/devices/THINKNODE_M1.png differ diff --git a/src/public/images/devices/T_ETH_ELITE.png b/src/public/images/devices/T_ETH_ELITE.png new file mode 100644 index 0000000..3a25fbc Binary files /dev/null and b/src/public/images/devices/T_ETH_ELITE.png differ diff --git a/src/public/images/devices/XIAO_NRF52_KIT.png b/src/public/images/devices/XIAO_NRF52_KIT.png new file mode 100644 index 0000000..f3cc2eb Binary files /dev/null and b/src/public/images/devices/XIAO_NRF52_KIT.png differ diff --git a/src/public/index.html b/src/public/index.html index d2f1f0d..ff76890 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -3,15 +3,15 @@ - STHLM-MESH MAP - + DL4AX Meshtastic Map + - - + + @@ -48,118 +48,8 @@ - + + @@ -168,32 +58,8 @@
- -
- - -
-
Viktigt!
-
- I Stockholm används LoRa preset Medium Range - Fast. För mer info klicka här. -
-
- - - - -
- -
+
@@ -207,13 +73,8 @@
- - - -
-
STHLM-MESH
+
+
@@ -251,16 +112,6 @@ - -
- - - -
-
- - - -
-
-
-
- - - - - -
- - -
- -

Meshtastic Map

-

Created by Liam Cottle

-

Forked by Roslund

-
- - -
-
Beskrivning
-
-
-
Detta är en karta som enbart fokuserar på Stockholm.
-
Den är baserad på Liam Cottle's open source projekt Meshtastic Map, men har flertalet ändringar och nya funktioner som gör att vi bättre kan analysera Meshen i Stockholm.
-
-
-
-
Frågor och svar
-
-
-
Hur får jag min nod att synas på kartan?
-
Din nod behöver anting ha en GPS, ha en fast position inställd, eller att din telefon delar sin position.
-
Utöver detta måste platsdelning vara påslåget under kanalinställningarna för MediumFast kanalen (vanligtvis kanal 0).
-
-
-
Min nod är på fel plats på kartan
-
Detta är troligtvis för att din nod inte delar exakt position. Som standard är positionsprecisionen inställd på ± 3 km, vilket betyder att noden kan befinna sig inom en cirkel med radien 3 kilometer.
-
Du kan ändra positions precisionen i kanalinställningarna. För mer info om positions precisionen, klicka här.
-
-
-
Hur kan jag ansluta min nod till MQTT servern?
-
Då vi enbart vill analysera Meshen i stockholm är MQTT servern inte öppen för alla. Endast ett fåtal noder är uppkopplade till MQTT för att kunna analysera trafiken som faktiskt går över LoRa.
-
De noder som är kopplade mot MQTT servern bör:
-
    -
  • Vara på en unik geografisk plats, då vi vill se hur trafiken fördelas
  • -
  • Ha en stabil fast koppling till internet (via Ethernet eller WiFi)
  • -
  • Ha hög tillgänglighet
  • -
  • Ha direktkontakt med flertalet andra noder
  • -
-
Tror du att din nod kan bidra, kontakta @Roslund på Discord.
-
-
-
- -
-
Legal
-
-
This project is not affiliated with or endorsed by the Meshtastic project.
-
The Meshtastic logo is the trademark of Meshtastic LLC.
-
Map tiles provided by OpenStreetMap
-
-
- - - - -
- -
-
-
-
- - -
- - - - - - - - - - + + +