diff --git a/README.md b/README.md index 2ee653b..854146a 100644 --- a/README.md +++ b/README.md @@ -65,11 +65,6 @@ 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 1fb2318..1e22fa5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "cors": "^2.8.5", "express": "^5.2.1", "mqtt": "^5.14.1", - "protobufjs": "^7.5.4", + "protobufjs": "^8.0.0", "ws": "^8.18.3" }, "devDependencies": { @@ -69,7 +69,6 @@ "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -2010,7 +2009,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001737", "electron-to-chromium": "^1.5.211", @@ -5150,7 +5148,6 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/config": "6.16.2", "@prisma/engines": "6.16.2" @@ -5186,9 +5183,9 @@ "license": "MIT" }, "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.0.0.tgz", + "integrity": "sha512-jx6+sE9h/UryaCZhsJWbJtTEy47yXoGNYI4z8ZaRncM0zBKeRqjO2JEcOUYwrYGb1WLhXM1FfMzW3annvFv0rw==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { diff --git a/package.json b/package.json index 989093f..b346c86 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "cors": "^2.8.5", "express": "^5.2.1", "mqtt": "^5.14.1", - "protobufjs": "^7.5.4", + "protobufjs": "^8.0.0", "ws": "^8.18.3" }, "devDependencies": { diff --git a/screenshot.png b/screenshot.png index 25ee627..13d17b2 100644 Binary files a/screenshot.png and b/screenshot.png differ diff --git a/src/mqtt.js b/src/mqtt.js index 67cb9dd..f7bf9b5 100644 --- a/src/mqtt.js +++ b/src/mqtt.js @@ -1096,7 +1096,7 @@ client.on("message", async (topic, message) => { // Extract edges from neighbour info try { - const toNodeId = envelope.packet.from; + const fromNodeId = envelope.packet.from; const neighbors = neighbourInfo.neighbors || []; const packetId = envelope.packet.id; const channelId = envelope.channelId; @@ -1115,7 +1115,7 @@ client.on("message", async (topic, message) => { continue; } - const fromNodeId = neighbour.nodeId; + const toNodeId = neighbour.nodeId; const snr = neighbour.snr; // Fetch node positions from Node table diff --git a/src/public/assets/css/styles.css b/src/public/assets/css/styles.css deleted file mode 100644 index 8811cad..0000000 --- a/src/public/assets/css/styles.css +++ /dev/null @@ -1,115 +0,0 @@ -/* 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 deleted file mode 100644 index 86b4542..0000000 --- a/src/public/assets/js/app.js +++ /dev/null @@ -1,956 +0,0 @@ -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 deleted file mode 100644 index 5866a4c..0000000 --- a/src/public/assets/js/config.js +++ /dev/null @@ -1,199 +0,0 @@ -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 deleted file mode 100644 index e25c067..0000000 --- a/src/public/assets/js/map.js +++ /dev/null @@ -1,1692 +0,0 @@ -// 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 deleted file mode 100644 index ae7a797..0000000 Binary files a/src/public/images/devices/HELTEC_MESH_POCKET.png and /dev/null differ diff --git a/src/public/images/devices/HELTEC_MESH_SOLAR.png b/src/public/images/devices/HELTEC_MESH_SOLAR.png deleted file mode 100644 index a6575ef..0000000 Binary files a/src/public/images/devices/HELTEC_MESH_SOLAR.png and /dev/null differ diff --git a/src/public/images/devices/HELTEC_V4.png b/src/public/images/devices/HELTEC_V4.png deleted file mode 100644 index 6dd4c49..0000000 Binary files a/src/public/images/devices/HELTEC_V4.png and /dev/null differ diff --git a/src/public/images/devices/PORTDUINO.png b/src/public/images/devices/PORTDUINO.png deleted file mode 100644 index 0ccea0d..0000000 Binary files a/src/public/images/devices/PORTDUINO.png and /dev/null differ diff --git a/src/public/images/devices/SEEED_SOLAR_NODE.png b/src/public/images/devices/SEEED_SOLAR_NODE.png deleted file mode 100644 index 7f894bc..0000000 Binary files a/src/public/images/devices/SEEED_SOLAR_NODE.png and /dev/null differ diff --git a/src/public/images/devices/SEEED_WIO_TRACKER_L1.png b/src/public/images/devices/SEEED_WIO_TRACKER_L1.png deleted file mode 100644 index e432076..0000000 Binary files a/src/public/images/devices/SEEED_WIO_TRACKER_L1.png and /dev/null differ diff --git a/src/public/images/devices/THINKNODE_M1.png b/src/public/images/devices/THINKNODE_M1.png deleted file mode 100644 index 7995026..0000000 Binary files a/src/public/images/devices/THINKNODE_M1.png and /dev/null differ diff --git a/src/public/images/devices/T_ETH_ELITE.png b/src/public/images/devices/T_ETH_ELITE.png deleted file mode 100644 index 3a25fbc..0000000 Binary files a/src/public/images/devices/T_ETH_ELITE.png and /dev/null differ diff --git a/src/public/images/devices/XIAO_NRF52_KIT.png b/src/public/images/devices/XIAO_NRF52_KIT.png deleted file mode 100644 index f3cc2eb..0000000 Binary files a/src/public/images/devices/XIAO_NRF52_KIT.png and /dev/null differ diff --git a/src/public/index.html b/src/public/index.html index ff76890..e9443d4 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -3,15 +3,15 @@ - DL4AX Meshtastic Map - + STHLM-MESH MAP + - - + + @@ -48,8 +48,118 @@ - - + @@ -58,8 +168,32 @@
+ +
+ + +
+
Viktigt!
+
+ I Stockholm används LoRa preset Medium Range - Fast. För mer info klicka här. +
+
+ + + + +
+ -
+
@@ -73,8 +207,13 @@
-
- + + + + @@ -112,6 +251,16 @@ + +
+ + + +
+
+ + + +
+
+
+
+ + + + + +
+ + +
+ +

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
+
+
+ + + + +
+ +
+
+
+
+ + +
+ + +
+ +
Nodes that have not uplinked to MQTT in this time will show as blue icons. Reload to update map.
+ +
+
@@ -1094,8 +1374,8 @@
- -
Edges from traceroutes and neighbour info within this time period are shown in the Connections layer. Reload to update map.
+ +
Edges within this time period are shown in the Connections layer. Reload to update map.
-
- -
-
Only show connections where data flows in both directions. Reload to update map.
-
- - -
- -
Only show connections where at least one direction has SNR above this threshold. Leave empty to show all connections. Reload to update map.
- -
-
- -
- -
-
If checked, all existing directions must meet the minimum SNR threshold (both directions if bidirectional, single direction if unidirectional).
-
-
@@ -1318,9 +1573,2740 @@
- - - + + + + + + + + +