diff --git a/README.md b/README.md
index 854146a..2ee653b 100644
--- a/README.md
+++ b/README.md
@@ -65,6 +65,11 @@ git clone https://github.com/liamcottle/meshtastic-map
cd meshtastic-map
```
+Install Meshtastic protobufs definitions
+```
+git clone https://github.com/meshtastic/protobufs src/protobufs
+```
+
Install NodeJS dependencies
```
diff --git a/package-lock.json b/package-lock.json
index 9f06e07..1fb2318 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,7 +17,7 @@
"express": "^5.2.1",
"mqtt": "^5.14.1",
"protobufjs": "^7.5.4",
- "ws": "^8.19.0"
+ "ws": "^8.18.3"
},
"devDependencies": {
"jest": "^30.1.3",
@@ -69,6 +69,7 @@
"integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
@@ -2009,6 +2010,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001737",
"electron-to-chromium": "^1.5.211",
@@ -5148,6 +5150,7 @@
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@prisma/config": "6.16.2",
"@prisma/engines": "6.16.2"
@@ -6376,9 +6379,9 @@
}
},
"node_modules/ws": {
- "version": "8.19.0",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
- "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
+ "version": "8.18.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
+ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
diff --git a/package.json b/package.json
index 6b29973..989093f 100644
--- a/package.json
+++ b/package.json
@@ -17,7 +17,7 @@
"express": "^5.2.1",
"mqtt": "^5.14.1",
"protobufjs": "^7.5.4",
- "ws": "^8.19.0"
+ "ws": "^8.18.3"
},
"devDependencies": {
"jest": "^30.1.3",
diff --git a/screenshot.png b/screenshot.png
index 13d17b2..25ee627 100644
Binary files a/screenshot.png and b/screenshot.png differ
diff --git a/src/mqtt.js b/src/mqtt.js
index f7bf9b5..67cb9dd 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 fromNodeId = envelope.packet.from;
+ const toNodeId = 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 toNodeId = neighbour.nodeId;
+ const fromNodeId = 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
new file mode 100644
index 0000000..8811cad
--- /dev/null
+++ b/src/public/assets/css/styles.css
@@ -0,0 +1,115 @@
+/* used to prevent ui flicker before vuejs loads */
+[v-cloak] {
+ display: none;
+}
+
+.icon-longfast {
+ background-color: #009016;
+ border-radius: 25px;
+ border: 1px solid #2C2D3C;
+}
+
+.icon-mediumfast {
+ background-color: #326be7;
+ border-radius: 25px;
+ border: 1px solid #2C2D3C;
+}
+
+.icon-shortslow {
+ background-color: #0077e6;
+ border-radius: 25px;
+ border: 1px solid #2C2D3C;
+}
+
+.icon-mqtt-connected {
+ background-color: #2563eb; /* Change to use same color as disconnected // #16a34a; */
+ border-radius: 25px;
+ border: 1px solid #2C2D3C;
+}
+
+.icon-mqtt-disconnected {
+ background-color: #2563eb;
+ border-radius: 25px;
+ border: 1px solid #2C2D3C;
+}
+
+.icon-offline {
+ background-color: #e2286c;
+ border-radius: 25px;
+ border: 1px solid #2C2D3C;
+}
+
+.icon-position-history {
+ background-color: #a855f7;
+ border-radius: 25px;
+ border: 1px solid #2C2D3C;
+}
+
+.icon-traceroute-start {
+ background-color: #16a34a; /* green */
+ border-radius: 25px;
+ border: 1px solid #2C2D3C;
+}
+
+.icon-traceroute-end {
+ background-color: #dc2626; /* red */
+ border-radius: 25px;
+ border: 1px solid #2C2D3C;
+}
+
+.waypoint-label {
+ font-size: 26px;
+ background-color: transparent;
+}
+
+.link {
+ color: #2563eb;
+}
+
+.link:hover {
+ text-decoration: underline;
+}
+
+.tooltip {
+ position: relative;
+ display: inline-block;
+}
+
+.tooltip .tooltip-text {
+ visibility: hidden;
+ width: 80px;
+ background-color: black;
+ color: #fff;
+ text-align: center;
+ padding: 4px 0;
+ border-radius: 6px;
+ position: absolute;
+ z-index: 10000;
+ top: 100%;
+ left: 50%;
+ margin-top: 8px;
+ margin-left: -40px; /* Use half of the width (120/2 = 60), to center the tooltip */
+}
+
+.tooltip .tooltip-text::after {
+ content: " ";
+ position: absolute;
+ bottom: 100%; /* At the top of the tooltip */
+ left: 50%;
+ margin-left: -5px;
+ border-width: 5px;
+ border-style: solid;
+ border-color: transparent transparent black transparent;
+}
+
+.tooltip:hover .tooltip-text {
+ visibility: visible;
+}
+
+.z-search {
+ z-index: 1001;
+}
+
+.z-sidebar {
+ z-index: 1002;
+}
\ No newline at end of file
diff --git a/src/public/assets/js/app.js b/src/public/assets/js/app.js
new file mode 100644
index 0000000..86b4542
--- /dev/null
+++ b/src/public/assets/js/app.js
@@ -0,0 +1,956 @@
+Vue.createApp({
+ data() {
+ return {
+
+ isShowingAnnouncement: this.shouldShowAnnouncement(),
+
+ configNodesMaxAgeInSeconds: window.getConfigNodesMaxAgeInSeconds(),
+ configNodesOfflineAgeInSeconds: window.getConfigNodesOfflineAgeInSeconds(),
+ configWaypointsMaxAgeInSeconds: window.getConfigWaypointsMaxAgeInSeconds(),
+ configConnectionsMaxDistanceInMeters: window.getConfigConnectionsMaxDistanceInMeters(),
+ configZoomLevelGoToNode: window.getConfigZoomLevelGoToNode(),
+ configAutoUpdatePositionInUrl: window.getConfigAutoUpdatePositionInUrl(),
+ configEnableMapAnimations: window.getConfigEnableMapAnimations(),
+ configTemperatureFormat: window.getConfigTemperatureFormat(),
+ configConnectionsTimePeriodInSeconds: window.getConfigConnectionsTimePeriodInSeconds(),
+ configConnectionsColoredLines: window.getConfigConnectionsColoredLines(),
+ configConnectionsBidirectionalOnly: window.getConfigConnectionsBidirectionalOnly(),
+ configConnectionsMinSnrDb: window.getConfigConnectionsMinSnrDb(),
+ configConnectionsBidirectionalMinSnr: window.getConfigConnectionsBidirectionalMinSnr(),
+
+ isShowingHardwareModels: false,
+ hardwareModelStats: null,
+
+ isShowingInfoModal: this.shouldShowInfoModal(),
+ isShowingMobileSearch: false,
+ isShowingSettings: false,
+
+ nodes: [],
+ searchText: "",
+
+ selectedNode: null,
+ selectedNodeDeviceMetrics: [],
+ selectedNodeEnvironmentMetrics: [],
+ selectedNodePowerMetrics: [],
+ selectedNodeMqttMetrics: [],
+ selectedNodeTraceroutes: [],
+
+ deviceMetricsTimeRange: "7d",
+ environmentMetricsTimeRange: "7d",
+ powerMetricsTimeRange: "7d",
+
+ isPositionHistoryModalExpanded: true,
+ positionHistoryDateTimeFrom: null,
+ positionHistoryDateTimeTo: null,
+ selectedNodePositionHistory: [],
+ selectedNodeToShowPositionHistory: null,
+ selectedNodePositionHistoryMarkers: [],
+ selectedNodePositionHistoryPolyLines: [],
+
+ selectedTraceRoute: null,
+ tracerouteEdges: [],
+
+ selectedNodeToShowConnections: null,
+
+ moment: window.moment,
+
+ };
+ },
+ mounted: function() {
+
+ // load data
+ this.loadHardwareModelStats();
+
+ // handle map click callback from outside of vue
+ window._onMapClick = () => {
+ this.searchText = "";
+ this.isShowingMobileSearch = false;
+ };
+
+ // handle node callback from outside of vue
+ window._onNodeClick = (node) => {
+ this.selectedNode = node;
+ this.loadNodeDeviceMetrics(node.node_id);
+ this.loadNodeEnvironmentMetrics(node.node_id);
+ this.loadNodePowerMetrics(node.node_id);
+ this.loadNodeMqttMetrics(node.node_id);
+ this.loadNodeTraceroutes(node.node_id);
+ //this.loadNodePositionHistory(node.node_id);
+ };
+
+ // handle node callback from outside of vue
+ window._onShowNodeConnectionsClick = (node) => {
+ this.selectedNodeToShowConnections = node;
+ };
+
+ // handle nodes updated callback from outside of vue
+ window._onNodesUpdated = (nodes) => {
+ this.nodes = nodes;
+ };
+
+ },
+ methods: {
+ getAnnouncementId: function() {
+ // change this when making a new announcement
+ return "1";
+ },
+ shouldShowAnnouncement: function() {
+ const lastSeenAnnouncementId = window.localStorage.getItem("last-seen-announcement-id");
+ return lastSeenAnnouncementId?.toString() !== this.getAnnouncementId();
+ },
+ dismissAnnouncement: function() {
+ window.localStorage.setItem("last-seen-announcement-id", this.getAnnouncementId());
+ this.isShowingAnnouncement = false;
+ },
+ shouldShowInfoModal: function() {
+ return !window.getConfigHasSeenInfoModal()
+ && !window.isMobile();
+ },
+ loadHardwareModelStats: function() {
+ window.axios.get('/api/v1/stats/hardware-models').then((response) => {
+ this.hardwareModelStats = response.data.hardware_model_stats;
+ }).catch((error) => {
+ // do nothing
+ });
+ },
+ loadNodeDeviceMetrics: function(nodeId) {
+
+ // calculate unix timestamps in milliseconds for supported time ranges
+ const oneDayAgoInMilliseconds = new Date().getTime() - (86400 * 1000);
+ const threeDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000);
+ const sevenDaysAgoInMilliseconds = new Date().getTime() - (604800 * 1000);
+ const thirtyDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000 * 10);
+
+ // determine how long back to load device metrics from
+ var timeFrom = threeDaysAgoInMilliseconds;
+ switch(this.deviceMetricsTimeRange){
+ case "1d": {
+ timeFrom = oneDayAgoInMilliseconds;
+ break;
+ }
+ case "3d": {
+ timeFrom = threeDaysAgoInMilliseconds;
+ break;
+ }
+ case "7d": {
+ timeFrom = sevenDaysAgoInMilliseconds;
+ break;
+ }
+ case "30d": {
+ timeFrom = thirtyDaysAgoInMilliseconds;
+ break;
+ }
+ }
+
+ window.axios.get(`/api/v1/nodes/${nodeId}/device-metrics`, {
+ params: {
+ time_from: timeFrom,
+ },
+ }).then((response) => {
+ // reverse response, as it's newest to oldest, but we want oldest to newest
+ this.selectedNodeDeviceMetrics = response.data.device_metrics.reverse();
+ this.renderDeviceMetricCharts();
+ }).catch(() => {
+ this.selectedNodeDeviceMetrics = [];
+ this.renderDeviceMetricCharts();
+ });
+ },
+ loadNodeEnvironmentMetrics: function(nodeId) {
+
+ // calculate unix timestamps in milliseconds for supported time ranges
+ const oneDayAgoInMilliseconds = new Date().getTime() - (86400 * 1000);
+ const threeDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000);
+ const sevenDaysAgoInMilliseconds = new Date().getTime() - (604800 * 1000);
+ const thirtyDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000 * 10);
+
+ // determine how long back to load environment metrics from
+ var timeFrom = threeDaysAgoInMilliseconds;
+ switch(this.environmentMetricsTimeRange){
+ case "1d": {
+ timeFrom = oneDayAgoInMilliseconds;
+ break;
+ }
+ case "3d": {
+ timeFrom = threeDaysAgoInMilliseconds;
+ break;
+ }
+ case "7d": {
+ timeFrom = sevenDaysAgoInMilliseconds;
+ break;
+ }
+ case "30d": {
+ timeFrom = thirtyDaysAgoInMilliseconds;
+ break;
+ }
+ }
+
+ window.axios.get(`/api/v1/nodes/${nodeId}/environment-metrics`, {
+ params: {
+ time_from: timeFrom,
+ },
+ }).then((response) => {
+ // reverse response, as it's newest to oldest, but we want oldest to newest
+ this.selectedNodeEnvironmentMetrics = response.data.environment_metrics.reverse();
+ this.renderEnvironmentMetricCharts();
+ }).catch(() => {
+ this.selectedNodeEnvironmentMetrics = [];
+ this.renderEnvironmentMetricCharts();
+ });
+ },
+ loadNodePowerMetrics: function(nodeId) {
+
+ // calculate unix timestamps in milliseconds for supported time ranges
+ const oneDayAgoInMilliseconds = new Date().getTime() - (86400 * 1000);
+ const threeDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000);
+ const sevenDaysAgoInMilliseconds = new Date().getTime() - (604800 * 1000);
+ const thirtyDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000 * 10);
+
+ // determine how long back to load power metrics from
+ var timeFrom = threeDaysAgoInMilliseconds;
+ switch(this.powerMetricsTimeRange){
+ case "1d": {
+ timeFrom = oneDayAgoInMilliseconds;
+ break;
+ }
+ case "3d": {
+ timeFrom = threeDaysAgoInMilliseconds;
+ break;
+ }
+ case "7d": {
+ timeFrom = sevenDaysAgoInMilliseconds;
+ break;
+ }
+ case "30d": {
+ timeFrom = thirtyDaysAgoInMilliseconds;
+ break;
+ }
+ }
+
+ window.axios.get(`/api/v1/nodes/${nodeId}/power-metrics`, {
+ params: {
+ time_from: timeFrom,
+ },
+ }).then((response) => {
+ // reverse response, as it's newest to oldest, but we want oldest to newest
+ this.selectedNodePowerMetrics = response.data.power_metrics.reverse();
+ this.renderPowerMetricCharts();
+ }).catch(() => {
+ this.selectedNodePowerMetrics = [];
+ this.renderPowerMetricCharts();
+ });
+ },
+ loadNodeMqttMetrics: function(nodeId) {
+ this.selectedNodeMqttMetrics = [];
+ window.axios.get(`/api/v1/nodes/${nodeId}/mqtt-metrics`).then((response) => {
+ this.selectedNodeMqttMetrics = response.data.mqtt_metrics;
+ }).catch(() => {
+ // do nothing
+ });
+ },
+ loadNodeTraceroutes: function(nodeId) {
+ this.selectedNodeTraceroutes = [];
+ window.axios.get(`/api/v1/nodes/${nodeId}/traceroutes`, {
+ params: {
+ count: 5,
+ },
+ }).then((response) => {
+ this.selectedNodeTraceroutes = response.data.traceroutes;
+ }).catch(() => {
+ // do nothing
+ });
+ },
+ loadNodePositionHistory: function(nodeId) {
+ this.selectedNodePositionHistory = [];
+ window.axios.get(`/api/v1/nodes/${nodeId}/position-history`, {
+ params: {
+ // parse from datetime-local format, and send as unix timestamp in milliseconds
+ time_from: moment(this.positionHistoryDateTimeFrom, "YYYY-MM-DDTHH:mm").format("x"),
+ time_to: moment(this.positionHistoryDateTimeTo, "YYYY-MM-DDTHH:mm").format("x"),
+ },
+ }).then((response) => {
+ this.selectedNodePositionHistory = response.data.position_history;
+ if(this.selectedNodeToShowPositionHistory != null){
+ clearAllPositionHistory();
+ onPositionHistoryUpdated(response.data.position_history);
+ }
+
+ }).catch(() => {
+ // do nothing
+ });
+ },
+ renderDeviceMetricCharts: function() {
+ try {
+ this.updateDeviceMetricsChart();
+ } catch(e) {
+ console.log(e);
+ }
+ },
+ updateDeviceMetricsChart: function() {
+
+ // destroy existing chart
+ const chartElementId = "deviceMetricsChart";
+ const existingChart = window.Chart.getChart(chartElementId);
+ if(existingChart != null){
+ existingChart.destroy();
+ }
+
+ // get chart element
+ const chartElement = window.document.getElementById(chartElementId);
+ if(!chartElement){
+ return;
+ }
+
+ // create chart data
+ const labels = [];
+ const batteryMetrics = [];
+ const channelUtilizationMetrics = [];
+ const airUtilTxMetrics = [];
+ for(const deviceMetric of this.selectedNodeDeviceMetrics){
+ labels.push(moment(deviceMetric.created_at));
+ batteryMetrics.push(deviceMetric.battery_level);
+ channelUtilizationMetrics.push(deviceMetric.channel_utilization);
+ airUtilTxMetrics.push(deviceMetric.air_util_tx);
+ }
+
+ // create chart
+ new window.Chart(chartElement, {
+ type: 'line',
+ data: {
+ labels: labels,
+ datasets: [
+ {
+ label: 'Battery Level',
+ borderColor: '#3b82f6',
+ backgroundColor: '#3b82f6',
+ pointStyle: false, // no points
+ fill: false,
+ data: batteryMetrics,
+ },
+ {
+ label: 'Channel Util',
+ borderColor: '#22c55e',
+ backgroundColor: '#22c55e',
+ showLine: false, // no lines between points
+ fill: false,
+ data: channelUtilizationMetrics,
+ },
+ {
+ label: 'Air Util TX',
+ borderColor: '#f97316',
+ backgroundColor: '#f97316',
+ showLine: false, // no lines between points
+ fill: false,
+ data: airUtilTxMetrics,
+
+ },
+ ],
+ },
+ options: {
+ responsive: true,
+ borderWidth: 2,
+ elements: {
+ point: {
+ radius: 2,
+ },
+ },
+ scales: {
+ x: {
+ position: 'top',
+ type: 'time',
+ time: {
+ unit: 'day',
+ displayFormats: {
+ day: 'MMM DD', // Jan 01
+ },
+ },
+ },
+ y: {
+ min: 0,
+ max: 101, // 101 is "Plugged In", need to include for tooltip to work
+ ticks: {
+ callback: (label) => `${label}%`,
+ },
+ },
+ },
+ plugins: {
+ legend: {
+ display: false,
+ },
+ tooltip: {
+ mode: "index",
+ intersect: false,
+ callbacks: {
+ label: (item) => {
+ return `${item.dataset.label}: ${item.formattedValue}%`;
+ },
+ },
+ },
+ },
+ }
+ });
+
+ },
+ renderEnvironmentMetricCharts: function() {
+ try {
+ this.updateEnvironmentMetricsChart();
+ } catch(e) {
+ console.log(e);
+ }
+ },
+ updateEnvironmentMetricsChart: function() {
+
+ // destroy existing chart
+ const chartElementId = "environmentMetricsChart";
+ const existingChart = window.Chart.getChart(chartElementId);
+ if(existingChart != null){
+ existingChart.destroy();
+ }
+
+ // get chart element
+ const chartElement = window.document.getElementById(chartElementId);
+ if(!chartElement){
+ return;
+ }
+
+ // create chart data
+ const labels = [];
+ const temperatureMetrics = [];
+ const relativeHumidityMetrics = [];
+ const barometricPressureMetrics = [];
+ const iaqMetrics = [];
+ for(const deviceMetric of this.selectedNodeEnvironmentMetrics){
+ labels.push(moment(deviceMetric.created_at));
+ temperatureMetrics.push(deviceMetric.temperature);
+ relativeHumidityMetrics.push(deviceMetric.relative_humidity);
+ barometricPressureMetrics.push(deviceMetric.barometric_pressure);
+ iaqMetrics.push(deviceMetric.iaq);
+ }
+
+ // create chart
+ new window.Chart(chartElement, {
+ type: 'line',
+ data: {
+ labels: labels,
+ datasets: [
+ {
+ label: 'Temperature',
+ suffix: '°C',
+ borderColor: '#3b82f6',
+ backgroundColor: '#3b82f6',
+ pointStyle: false, // no points
+ fill: false,
+ data: temperatureMetrics,
+ yAxisID: 'y',
+ },
+ {
+ label: 'Humidity',
+ suffix: '%',
+ borderColor: '#22c55e',
+ backgroundColor: '#22c55e',
+ pointStyle: false, // no points
+ fill: false,
+ data: relativeHumidityMetrics,
+ yAxisID: 'y',
+ },
+ {
+ label: 'Pressure',
+ suffix: 'hPa',
+ borderColor: '#f97316',
+ backgroundColor: '#f97316',
+ pointStyle: false, // no points
+ fill: false,
+ data: barometricPressureMetrics,
+ yAxisID: 'y1',
+
+ },
+ {
+ label: 'IAQ',
+ suffix: 'IAQ',
+ borderColor: '#f472b6',
+ backgroundColor: '#f472b6',
+ pointStyle: false, // no points
+ fill: false,
+ data: iaqMetrics,
+ yAxisID: 'yIAQ',
+
+ },
+ ],
+ },
+ options: {
+ responsive: true,
+ borderWidth: 2,
+ spanGaps: 1000 * 60 * 60 * 24, // only show lines between metrics with a 24 hour or less gap
+ elements: {
+ point: {
+ radius: 2,
+ },
+ },
+ scales: {
+ x: {
+ position: 'top',
+ type: 'time',
+ time: {
+ unit: 'day',
+ displayFormats: {
+ day: 'MMM DD', // Jan 01
+ },
+ },
+ },
+ y: {
+ min: -20,
+ max: 100,
+ },
+ y1: {
+ min: 800,
+ max: 1100,
+ ticks: {
+ stepSize: 10,
+ callback: (label) => `${label} hPa`,
+ },
+ position: 'right',
+ grid: {
+ drawOnChartArea: false, // only want the grid lines for one axis to show up
+ },
+ },
+ yIAQ: {
+ type: 'linear',
+ display: false,
+ },
+ },
+ plugins: {
+ legend: {
+ display: false,
+ },
+ tooltip: {
+ mode: "index",
+ intersect: false,
+ callbacks: {
+ label: (item) => {
+ return `${item.dataset.label}: ${item.formattedValue}${item.dataset.suffix}`;
+ },
+ },
+ },
+ },
+ }
+ });
+
+ },
+ renderPowerMetricCharts: function() {
+ try {
+ this.updatePowerMetricsChart();
+ } catch(e) {
+ console.log(e);
+ }
+ },
+ updatePowerMetricsChart: function() {
+
+ // destroy existing chart
+ const chartElementId = "powerMetricsChart";
+ const existingChart = window.Chart.getChart(chartElementId);
+ if(existingChart != null){
+ existingChart.destroy();
+ }
+
+ // get chart element
+ const chartElement = window.document.getElementById(chartElementId);
+ if(!chartElement){
+ return;
+ }
+
+ // create chart data
+ const labels = [];
+ const channel1VoltageReadings = [];
+ const channel2VoltageReadings = [];
+ const channel3VoltageReadings = [];
+ const channel1CurrentReadings = [];
+ const channel2CurrentReadings = [];
+ const channel3CurrentReadings = [];
+ for(const powerMetric of this.selectedNodePowerMetrics){
+ labels.push(moment(powerMetric.created_at));
+ channel1VoltageReadings.push(powerMetric.ch1_voltage);
+ channel2VoltageReadings.push(powerMetric.ch2_voltage);
+ channel3VoltageReadings.push(powerMetric.ch3_voltage);
+ channel1CurrentReadings.push(powerMetric.ch1_current);
+ channel2CurrentReadings.push(powerMetric.ch2_current);
+ channel3CurrentReadings.push(powerMetric.ch3_current);
+ }
+
+ // create chart
+ new window.Chart(chartElement, {
+ type: 'line',
+ data: {
+ labels: labels,
+ datasets: [
+ {
+ label: 'Ch1 Voltage',
+ suffix: "V",
+ borderColor: '#3b82f6',
+ backgroundColor: '#3b82f6',
+ pointStyle: false, // no points
+ fill: false,
+ data: channel1VoltageReadings,
+ yAxisID: 'y',
+ },
+ {
+ label: 'Ch2 Voltage',
+ suffix: "V",
+ borderColor: '#22c55e',
+ backgroundColor: '#22c55e',
+ pointStyle: false, // no points
+ fill: false,
+ data: channel2VoltageReadings,
+ yAxisID: 'y',
+ },
+ {
+ label: 'Ch3 Voltage',
+ suffix: "V",
+ borderColor: '#f97316',
+ backgroundColor: '#f97316',
+ pointStyle: false, // no points
+ fill: false,
+ data: channel3VoltageReadings,
+ yAxisID: 'y',
+ },
+ {
+ label: 'Ch1 Current',
+ suffix: "mA",
+ borderColor: '#93c5fd',
+ backgroundColor: '#93c5fd',
+ pointStyle: false, // no points
+ fill: false,
+ data: channel1CurrentReadings,
+ yAxisID: 'y1',
+ },
+ {
+ label: 'Ch2 Current',
+ suffix: "mA",
+ borderColor: '#86efac',
+ backgroundColor: '#86efac',
+ pointStyle: false, // no points
+ fill: false,
+ data: channel2CurrentReadings,
+ yAxisID: 'y1',
+ },
+ {
+ label: 'Ch3 Current',
+ suffix: "mA",
+ borderColor: '#fdba74',
+ backgroundColor: '#fdba74',
+ pointStyle: false, // no points
+ fill: false,
+ data: channel3CurrentReadings,
+ yAxisID: 'y1',
+ },
+ ],
+ },
+ options: {
+ responsive: true,
+ borderWidth: 2,
+ spanGaps: 1000 * 60 * 60 * 3, // only show lines between metrics with a 3 hour or less gap
+ elements: {
+ point: {
+ radius: 2,
+ },
+ },
+ scales: {
+ x: {
+ position: 'top',
+ type: 'time',
+ time: {
+ unit: 'day',
+ displayFormats: {
+ day: 'MMM DD', // Jan 01
+ },
+ },
+ },
+ y: {
+ min: 0,
+ suggestedMax: 6,
+ ticks: {
+ callback: (label) => `${label}V`,
+ },
+ },
+ y1: {
+ suggestedMin: -50,
+ suggestedMax: 50,
+ ticks: {
+ stepSize: 50,
+ callback: (label) => `${label}mA`,
+ },
+ position: 'right',
+ grid: {
+ drawOnChartArea: false, // only want the grid lines for one axis to show up
+ },
+ },
+ },
+ plugins: {
+ legend: {
+ display: false,
+ },
+ tooltip: {
+ mode: "index",
+ intersect: false,
+ callbacks: {
+ label: (item) => {
+ return `${item.dataset.label}: ${item.formattedValue}${item.dataset.suffix}`;
+ },
+ },
+ },
+ },
+ }
+ });
+
+ },
+ showTraceRoute: function(traceroute) {
+ this.selectedTraceRoute = traceroute;
+ },
+ findNodeById: function(id) {
+ return window.findNodeById(id);
+ },
+ findNodeMarkerById: function(id) {
+ return window.findNodeMarkerById(id);
+ },
+ onSearchResultNodeClick: function(node) {
+
+ // clear search
+ this.searchText = "";
+
+ // hide search
+ this.isShowingMobileSearch = false;
+
+ // go to node
+ if(window.goToNode(node.node_id)){
+ return;
+ }
+
+ // fallback to showing node details since we can't go to the node
+ window.showNodeDetails(node.node_id);
+
+ },
+ dismissInfoModal: function() {
+ this.isShowingInfoModal = false;
+ window.setConfigHasSeenInfoModal(true);
+ },
+ getRegionFrequencyRange: function(regionName) {
+ return window.getRegionFrequencyRange(regionName);
+ },
+ showNodePositionHistory: function(nodeId) {
+
+ // find node
+ const node = findNodeById(nodeId);
+ if(!node){
+ return;
+ }
+
+ // update ui
+ this.selectedNode = null;
+ this.selectedNodeToShowPositionHistory = node;
+ this.isPositionHistoryModalExpanded = true;
+
+ // close node info tooltip as position history shows under it
+ window.closeAllTooltips();
+
+ // reset default time range when opening position history ui
+ // YYYY-MM-DDTHH:mm is the format expected by the datetime-local input type
+ this.positionHistoryDateTimeFrom = moment().subtract(1, "hours").format('YYYY-MM-DDTHH:mm');
+ this.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm');
+
+ // load position history
+ this.loadNodePositionHistory(nodeId);
+
+ },
+ onPositionHistoryQuickRangeClick: function(range) {
+
+ // update position history time range
+ switch(range){
+ case "1h": {
+ this.positionHistoryDateTimeFrom = moment().subtract(1, "hours").format('YYYY-MM-DDTHH:mm');
+ this.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm');
+ break;
+ }
+ case "24h": {
+ this.positionHistoryDateTimeFrom = moment().subtract(24, "hours").format('YYYY-MM-DDTHH:mm');
+ this.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm');
+ break;
+ }
+ case "7d": {
+ this.positionHistoryDateTimeFrom = moment().subtract(7, "days").format('YYYY-MM-DDTHH:mm');
+ this.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm');
+ break;
+ }
+ }
+
+ // reload position history
+ const node = this.selectedNodeToShowPositionHistory;
+ if(node){
+ this.loadNodePositionHistory(node.node_id);
+ }
+
+ },
+ getShareLinkForNode: function(nodeId) {
+ return window.location.origin + `/?node_id=${nodeId}`;
+ },
+ copyShareLinkForNode: function(nodeId) {
+
+ // make sure copy to clipboard is supported
+ if(!navigator.clipboard || !navigator.clipboard.writeText){
+ alert("Clipboard not supported. Site must be served via https on iOS.");
+ return;
+ }
+
+ // copy share link to clipboard
+ const url = this.getShareLinkForNode(nodeId);
+ navigator.clipboard.writeText(url);
+
+ // tell user we copied it
+ alert("Link copied to clipboard!");
+
+ },
+ dismissShowingNodeConnections: function() {
+ window._onHideNodeConnectionsClick();
+ this.selectedNodeToShowConnections = null;
+ },
+ dismissShowingNodePositionHistory: function() {
+ this.selectedNodePositionHistory = [];
+ this.selectedNodeToShowPositionHistory = null;
+ this.selectedNodePositionHistoryMarkers = [];
+ this.selectedNodePositionHistoryPolyLines = [];
+ cleanUpPositionHistory();
+ },
+ formatUptimeSeconds: function(secondsToFormat) {
+ secondsToFormat = Number(secondsToFormat);
+ var days = Math.floor(secondsToFormat / (3600 * 24));
+ var hours = Math.floor((secondsToFormat % (3600 * 24)) / 3600);
+ var minutes = Math.floor((secondsToFormat % 3600) / 60);
+ var seconds = Math.floor(secondsToFormat % 60);
+ var daysPlural = days === 1 ? 'day' : 'days';
+ return `${days} ${daysPlural} ${hours}h ${minutes}m ${seconds}s`;
+ },
+ formatTemperature: function(celsius) {
+ switch(this.configTemperatureFormat){
+ case "celsius": {
+ return `${Number(celsius).toFixed(0)}°C`;
+ }
+ case "fahrenheit": {
+ const fahrenheit = this.celsiusToFahrenheit(celsius);
+ return `${fahrenheit.toFixed(0)}°F`;
+ }
+ }
+ },
+ convertTemperature: function(celsius) {
+ switch(this.configTemperatureFormat){
+ case "celsius": {
+ return celsius;
+ }
+ case "fahrenheit": {
+ return this.celsiusToFahrenheit(celsius);
+ }
+ }
+ },
+ getTemperatureUnit: function() {
+ switch(this.configTemperatureFormat){
+ case "celsius": return "°C";
+ case "fahrenheit": return "°F";
+ }
+ },
+ celsiusToFahrenheit: function(celsius) {
+ return (celsius * 9/5) + 32;
+ },
+ getNodeColour(nodeId) {
+ // convert node id to a hex colour
+ return "#" + (nodeId & 0x00FFFFFF).toString(16).padStart(6, '0');
+ },
+ getNodeTextColour(nodeId) {
+
+ // extract rgb components
+ const r = (nodeId & 0xFF0000) >> 16;
+ const g = (nodeId & 0x00FF00) >> 8;
+ const b = nodeId & 0x0000FF;
+
+ // calculate brightness
+ const brightness = ((r * 0.299) + (g * 0.587) + (b * 0.114)) / 255;
+
+ // determine text color based on brightness
+ return brightness > 0.5 ? "#000000" : "#FFFFFF";
+
+ },
+ },
+ computed: {
+ searchedNodes() {
+
+ // search nodes
+ const nodes = this.nodes.filter((node) => {
+ const matchesId = node.node_id?.toLowerCase()?.includes(this.searchText.toLowerCase());
+ const matchesHexId = node.node_id_hex?.toLowerCase()?.includes(this.searchText.toLowerCase());
+ const matchesLongName = node.long_name?.toLowerCase()?.includes(this.searchText.toLowerCase());
+ const matchesShortName = node.short_name?.toLowerCase()?.includes(this.searchText.toLowerCase());
+ return matchesId || matchesHexId || matchesLongName || matchesShortName;
+ });
+
+ // order alphabetically by long name
+ nodes.sort((nodeA, nodeB) => {
+ const nodeALongName = nodeA.long_name || "";
+ const nodeBLongName = nodeB.long_name || "";
+ return nodeALongName.localeCompare(nodeBLongName);
+ });
+
+ // only return the first 500 results to avoid ui lag...
+ return nodes.slice(0, 500);
+
+ },
+ selectedNodeLatestPowerMetric() {
+ const [ latestPowerMetric ] = this.selectedNodePowerMetrics.slice(-1);
+ return latestPowerMetric;
+ },
+ },
+ watch: {
+ configNodesMaxAgeInSeconds() {
+ window.setConfigNodesMaxAgeInSeconds(this.configNodesMaxAgeInSeconds);
+ },
+ configNodesOfflineAgeInSeconds() {
+ window.setConfigNodesOfflineAgeInSeconds(this.configNodesOfflineAgeInSeconds);
+ },
+ configWaypointsMaxAgeInSeconds() {
+ window.setConfigWaypointsMaxAgeInSeconds(this.configWaypointsMaxAgeInSeconds);
+ },
+ configConnectionsMaxDistanceInMeters() {
+ window.setConfigConnectionsMaxDistanceInMeters(this.configConnectionsMaxDistanceInMeters);
+ },
+ configZoomLevelGoToNode() {
+ window.setConfigZoomLevelGoToNode(this.configZoomLevelGoToNode);
+ },
+ configAutoUpdatePositionInUrl() {
+ window.setConfigAutoUpdatePositionInUrl(this.configAutoUpdatePositionInUrl);
+ },
+ configEnableMapAnimations() {
+ window.setConfigEnableMapAnimations(this.configEnableMapAnimations);
+ },
+ configTemperatureFormat() {
+ window.setConfigTemperatureFormat(this.configTemperatureFormat);
+ },
+ configConnectionsTimePeriodInSeconds() {
+ window.setConfigConnectionsTimePeriodInSeconds(this.configConnectionsTimePeriodInSeconds);
+ },
+ configConnectionsColoredLines() {
+ window.setConfigConnectionsColoredLines(this.configConnectionsColoredLines);
+ },
+ configConnectionsBidirectionalOnly() {
+ window.setConfigConnectionsBidirectionalOnly(this.configConnectionsBidirectionalOnly);
+ },
+ configConnectionsMinSnrDb() {
+ window.setConfigConnectionsMinSnrDb(this.configConnectionsMinSnrDb);
+ },
+ configConnectionsBidirectionalMinSnr() {
+ window.setConfigConnectionsBidirectionalMinSnr(this.configConnectionsBidirectionalMinSnr);
+ },
+ deviceMetricsTimeRange() {
+ this.loadNodeDeviceMetrics(this.selectedNode.node_id);
+ },
+ environmentMetricsTimeRange() {
+ this.loadNodeEnvironmentMetrics(this.selectedNode.node_id);
+ },
+ powerMetricsTimeRange() {
+ this.loadNodePowerMetrics(this.selectedNode.node_id);
+ },
+ },
+}).mount('#app');
\ No newline at end of file
diff --git a/src/public/assets/js/config.js b/src/public/assets/js/config.js
new file mode 100644
index 0000000..5866a4c
--- /dev/null
+++ b/src/public/assets/js/config.js
@@ -0,0 +1,199 @@
+function getConfigHasSeenInfoModal() {
+ return localStorage.getItem("config_has_seen_info_modal") === "true";
+}
+
+function setConfigHasSeenInfoModal(value) {
+ return localStorage.setItem("config_has_seen_info_modal", value);
+}
+
+function getConfigAutoUpdatePositionInUrl() {
+ // use user preference, or enable by default
+ const value = localStorage.getItem("config_auto_update_position_in_url");
+ return value === "true" || value == null;
+}
+
+function setConfigAutoUpdatePositionInUrl(value) {
+ return localStorage.setItem("config_auto_update_position_in_url", value);
+}
+
+function getConfigEnableMapAnimations() {
+
+ const value = localStorage.getItem("config_enable_map_animations");
+
+ // enable animations by default
+ if(value === null){
+ return true;
+ }
+
+ return value === "true";
+
+}
+
+function setConfigEnableMapAnimations(value) {
+ return localStorage.setItem("config_enable_map_animations", value);
+}
+
+function getConfigTemperatureFormat() {
+ return localStorage.getItem("config_temperature_format") || "celsius";
+}
+
+function setConfigTemperatureFormat(format) {
+ return localStorage.setItem("config_temperature_format", format);
+}
+
+function getConfigMapSelectedTileLayer() {
+ return localStorage.getItem("config_map_selected_tile_layer") || "Thunderforest Neighbourhood";
+}
+
+function setConfigMapSelectedTileLayer(layer) {
+ return localStorage.setItem("config_map_selected_tile_layer", layer);
+}
+
+function getConfigMapEnabledOverlayLayers() {
+
+ try {
+ const value = localStorage.getItem("config_map_enabled_overlay_layers");
+ if(value){
+ return JSON.parse(value);
+ }
+ } catch(e) {}
+
+ // overlays enabled by default
+ return ["Legend", "Position History", "Traceroutes"];
+
+}
+
+function setConfigMapEnabledOverlayLayers(layers) {
+ return localStorage.setItem("config_map_enabled_overlay_layers", JSON.stringify(layers));
+}
+
+function getConfigNodesMaxAgeInSeconds() {
+ const value = localStorage.getItem("config_nodes_max_age_in_seconds");
+ return value != null ? parseInt(value) : null;
+}
+
+function setConfigNodesMaxAgeInSeconds(value) {
+ if(value != null){
+ return localStorage.setItem("config_nodes_max_age_in_seconds", value);
+ } else {
+ return localStorage.removeItem("config_nodes_max_age_in_seconds");
+ }
+}
+
+function getConfigNodesOfflineAgeInSeconds() {
+ const value = localStorage.getItem("config_nodes_offline_age_in_seconds");
+ return value != null ? parseInt(value) : 10800;
+}
+
+function setConfigNodesOfflineAgeInSeconds(value) {
+ if(value != null){
+ return localStorage.setItem("config_nodes_offline_age_in_seconds", value);
+ } else {
+ return localStorage.removeItem("config_nodes_offline_age_in_seconds");
+ }
+}
+
+function getConfigWaypointsMaxAgeInSeconds() {
+ const value = localStorage.getItem("config_waypoints_max_age_in_seconds");
+ return value != null ? parseInt(value) : null;
+}
+
+function setConfigWaypointsMaxAgeInSeconds(value) {
+ if(value != null){
+ return localStorage.setItem("config_waypoints_max_age_in_seconds", value);
+ } else {
+ return localStorage.removeItem("config_waypoints_max_age_in_seconds");
+ }
+}
+
+function getConfigConnectionsMaxDistanceInMeters() {
+ const value = localStorage.getItem("config_connections_max_distance_in_meters");
+ // default to 70km (70,000 meters)
+ return value != null ? parseInt(value) : 70000;
+}
+
+function setConfigConnectionsMaxDistanceInMeters(value) {
+ return localStorage.setItem("config_connections_max_distance_in_meters", value);
+}
+
+function getConfigZoomLevelGoToNode() {
+ const value = localStorage.getItem("config_zoom_level_go_to_node");
+ const parsedValue = value != null ? parseInt(value) : null;
+ return parsedValue || 15;
+}
+
+function setConfigZoomLevelGoToNode(value) {
+ return localStorage.setItem("config_zoom_level_go_to_node", value);
+}
+
+function getConfigConnectionsTimePeriodInSeconds() {
+ const value = localStorage.getItem("config_connections_time_period_in_seconds");
+ // default to 7 days if unset
+ return value != null ? parseInt(value) : 604800;
+}
+
+function setConfigConnectionsTimePeriodInSeconds(value) {
+ return localStorage.setItem("config_connections_time_period_in_seconds", value);
+}
+
+function getConfigConnectionsColoredLines() {
+ const value = localStorage.getItem("config_connections_colored_lines");
+ // disable colored lines by default
+ if(value === null){
+ return false;
+ }
+ return value === "true";
+}
+
+function setConfigConnectionsColoredLines(value) {
+ return localStorage.setItem("config_connections_colored_lines", value);
+}
+
+function getConfigConnectionsBidirectionalOnly() {
+ const value = localStorage.getItem("config_connections_bidirectional_only");
+ // disable bidirectional filter by default
+ if(value === null){
+ return false;
+ }
+ return value === "true";
+}
+
+function setConfigConnectionsBidirectionalOnly(value) {
+ return localStorage.setItem("config_connections_bidirectional_only", value);
+}
+
+function getConfigConnectionsMinSnrDb() {
+ const value = localStorage.getItem("config_connections_min_snr_db");
+ // default to null (unset)
+ if(value === null || value === ""){
+ return null;
+ }
+ const parsed = parseFloat(value);
+ return isNaN(parsed) ? null : parsed;
+}
+
+function setConfigConnectionsMinSnrDb(value) {
+ if(value === null || value === "" || value === undefined){
+ return localStorage.removeItem("config_connections_min_snr_db");
+ }
+ // Convert to string for localStorage (handles both number and string inputs)
+ const stringValue = typeof value === "number" ? value.toString() : String(value);
+ return localStorage.setItem("config_connections_min_snr_db", stringValue);
+}
+
+function getConfigConnectionsBidirectionalMinSnr() {
+ const value = localStorage.getItem("config_connections_bidirectional_min_snr");
+ // disable bidirectional minimum SNR by default
+ if(value === null){
+ return false;
+ }
+ return value === "true";
+}
+
+function setConfigConnectionsBidirectionalMinSnr(value) {
+ return localStorage.setItem("config_connections_bidirectional_min_snr", value);
+}
+
+function isMobile() {
+ return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
+}
\ No newline at end of file
diff --git a/src/public/assets/js/map.js b/src/public/assets/js/map.js
new file mode 100644
index 0000000..e25c067
--- /dev/null
+++ b/src/public/assets/js/map.js
@@ -0,0 +1,1692 @@
+// global state
+var nodes = [];
+var nodeMarkers = {};
+var selectedNodeOutlineCircle = null;
+var waypoints = [];
+
+// set map bounds to be a little more than full size to prevent panning off screen
+var bounds = [
+ [-100, 70], // top left
+ [100, 500], // bottom right
+];
+
+// create map positioned over NRW
+if(!isMobile()){
+ var map = L.map('map', {
+ maxBounds: bounds,
+ }).setView([
+ 51.1,
+ 366.82,
+ ], 9);
+} else {
+ var map = L.map('map', {
+ maxBounds: bounds,
+ }).setView([
+ 51.1,
+ 366.82,
+ ], 8);
+}
+
+// remove leaflet link
+map.attributionControl.setPrefix('');
+
+var openThunderforestLandscapeMapTileLayer = L.tileLayer('https://tiles.nixware.dev/landscape/{z}/{x}/{y}.png', {
+ maxZoom: 22,
+ attribution: 'Tiles © Gravitystorm Limited | Data from Meshtastic ',
+});
+
+var openThunderforestAtlasMapTileLayer = L.tileLayer('https://tiles.nixware.dev/atlas/{z}/{x}/{y}.png', {
+ maxZoom: 22,
+ attribution: 'Tiles © Gravitystorm Limited | Data from Meshtastic ',
+});
+
+var openThunderforestNeighbourhoodMapTileLayer = L.tileLayer('https://tiles.nixware.dev/neighbourhood/{z}/{x}/{y}.png', {
+ maxZoom: 22,
+ attribution: 'Tiles © Gravitystorm Limited | Data from Meshtastic ',
+});
+
+var openStreetMapTileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ maxZoom: 22, // increase from 18 to 22
+ attribution: 'Tiles © OpenStreetMap | Data from Meshtastic ',
+});
+
+var openTopoMapTileLayer = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
+ maxZoom: 17, // open topo map doesn't have tiles closer than this
+ attribution: 'Tiles © OpenStreetMap | Data from Meshtastic ',
+});
+
+var esriWorldImageryTileLayer = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
+ maxZoom: 21, // esri doesn't have tiles closer than this
+ attribution: 'Tiles © Esri | Data from Meshtastic '
+});
+
+var googleSatelliteTileLayer = L.tileLayer('https://{s}.google.com/vt/lyrs=s&x={x}&y={y}&z={z}', {
+ maxZoom: 21,
+ subdomains: ['mt0', 'mt1', 'mt2', 'mt3'],
+ attribution: 'Tiles © Google | Data from Meshtastic '
+});
+
+var googleHybridTileLayer = L.tileLayer('https://{s}.google.com/vt/lyrs=s,h&x={x}&y={y}&z={z}', {
+ maxZoom: 21,
+ subdomains: ['mt0', 'mt1', 'mt2', 'mt3'],
+ attribution: 'Tiles © Google | Data from Meshtastic '
+});
+
+var tileLayers = {
+ "Thunderforest Neighbourhood": openThunderforestNeighbourhoodMapTileLayer,
+ "Thunderforest Landscape": openThunderforestLandscapeMapTileLayer,
+ "Thunderforest Atlas": openThunderforestAtlasMapTileLayer,
+ "OpenStreetMap": openStreetMapTileLayer,
+ "OpenTopoMap": openTopoMapTileLayer,
+ "Esri Satellite": esriWorldImageryTileLayer,
+ "Google Satellite": googleSatelliteTileLayer,
+ "Google Hybrid": googleHybridTileLayer,
+};
+
+// use tile layer based on config
+const selectedTileLayerName = getConfigMapSelectedTileLayer();
+const selectedTileLayer = tileLayers[selectedTileLayerName] || openThunderforestNeighbourhoodMapTileLayer;
+selectedTileLayer.addTo(map);
+
+// create layer groups
+var nodesLayerGroup = new L.LayerGroup();
+var backboneConnectionsLayerGroup = new L.LayerGroup();
+var nodeConnectionsLayerGroup = new L.LayerGroup();
+var nodesClusteredLayerGroup = L.markerClusterGroup({
+ showCoverageOnHover: false,
+ disableClusteringAtZoom: 10, // zoom level where node clustering is disabled
+});
+var nodesRouterLayerGroup = L.markerClusterGroup({
+ showCoverageOnHover: false,
+ disableClusteringAtZoom: 10, // zoom level where node clustering is disabled
+});
+var nodesBackboneLayerGroup = new L.LayerGroup();
+//var nodesMediumFastLayerGroup = new L.LayerGroup();
+var nodesShortSlowLayerGroup = new L.LayerGroup();
+var nodesLongFastLayerGroup = new L.LayerGroup();
+var waypointsLayerGroup = new L.LayerGroup();
+var nodePositionHistoryLayerGroup = new L.LayerGroup();
+var traceroutesLayerGroup = new L.LayerGroup();
+var connectionsLayerGroup = new L.LayerGroup();
+
+// create icons
+var iconMqttConnected = L.divIcon({
+ className: 'icon-mqtt-connected',
+ iconSize: [16, 16], // increase from 12px to 16px to make hover easier
+});
+
+var iconLongFast = L.divIcon({
+ className: 'icon-longfast',
+ iconSize: [16, 16], // increase from 12px to 16px to make hover easier
+});
+
+/*var iconMediumFast = L.divIcon({
+ className: 'icon-mediumfast',
+ iconSize: [16, 16], // increase from 12px to 16px to make hover easier
+});*/
+
+var iconShortSlow = L.divIcon({
+ className: 'icon-shortslow',
+ iconSize: [16, 16],
+});
+
+var iconMqttDisconnected = L.divIcon({
+ className: 'icon-mqtt-disconnected',
+ iconSize: [16, 16], // increase from 12px to 16px to make hover easier
+});
+
+var iconOffline = L.divIcon({
+ className: 'icon-offline',
+ iconSize: [16, 16], // increase from 12px to 16px to make hover easier
+});
+
+var iconPositionHistory = L.divIcon({
+ className: 'icon-position-history',
+ iconSize: [16, 16], // increase from 12px to 16px to make hover easier
+});
+
+var iconTracerouteStart = L.divIcon({
+ className: 'icon-traceroute-start',
+ iconSize: [16, 16],
+});
+
+var iconTracerouteEnd = L.divIcon({
+ className: 'icon-traceroute-end',
+ iconSize: [16, 16],
+});
+
+// create legend
+var legendLayerGroup = new L.LayerGroup();
+var legend = L.control({position: 'bottomleft'});
+legend.onAdd = function (map) {
+ var div = L.DomUtil.create('div', 'leaflet-control-layers');
+ div.style.backgroundColor = 'white';
+ div.style.padding = '12px';
+ div.innerHTML = `
Legend
`
+ + ``
+ //+ ``
+ + ``
+ + ``
+ + ` Traceroute
`;
+ return div;
+};
+
+// handle baselayerchange to update tile layer preference
+map.on('baselayerchange', function(event) {
+ setConfigMapSelectedTileLayer(event.name);
+});
+
+// handle adding/remove legend on map (can't use L.Control as an overlay, so we toggle an empty L.LayerGroup)
+map.on('overlayadd overlayremove', function(event) {
+ if(event.name === "Legend"){
+ if(event.type === "overlayadd"){
+ map.addControl(legend);
+ } else if(event.type === "overlayremove"){
+ map.removeControl(legend);
+ }
+ }
+});
+
+// add layers to control ui
+L.control.groupedLayers(tileLayers, {
+ "Nodes": {
+ "All": nodesLayerGroup,
+ "Routers": nodesRouterLayerGroup,
+ "Backbone": nodesBackboneLayerGroup,
+ "ShortSlow": nodesShortSlowLayerGroup,
+ //"MediumFast": nodesMediumFastLayerGroup,
+ "LongFast": nodesLongFastLayerGroup,
+ "Clustered": nodesClusteredLayerGroup,
+ "None": new L.LayerGroup(),
+ },
+ "Overlays": {
+ "Legend": legendLayerGroup,
+ "Backbone Connections": backboneConnectionsLayerGroup,
+ "Connections": connectionsLayerGroup,
+ "Waypoints": waypointsLayerGroup,
+ "Position History": nodePositionHistoryLayerGroup,
+ "Traceroutes": traceroutesLayerGroup,
+ },
+}, {
+ // make the "Nodes" group exclusive (use radio inputs instead of checkbox)
+ exclusiveGroups: ["Nodes"],
+}).addTo(map);
+
+// enable base layers
+nodesLayerGroup.addTo(map);
+
+// enable overlay layers based on config
+const enabledOverlayLayers = getConfigMapEnabledOverlayLayers();
+if(enabledOverlayLayers.includes("Legend")){
+ legendLayerGroup.addTo(map);
+}
+if(enabledOverlayLayers.includes("Backbone Connection")){
+ backboneConnectionsLayerGroup.addTo(map);
+}
+if(enabledOverlayLayers.includes("Connections")){
+ connectionsLayerGroup.addTo(map);
+}
+if(enabledOverlayLayers.includes("Waypoints")){
+ waypointsLayerGroup.addTo(map);
+}
+if(enabledOverlayLayers.includes("Position History")){
+ nodePositionHistoryLayerGroup.addTo(map);
+}
+if(enabledOverlayLayers.includes("Traceroutes")){
+ traceroutesLayerGroup.addTo(map);
+}
+
+map.on('overlayadd', function(event) {
+ // update config when map overlay is added
+ const layerName = event.name;
+ const enabledOverlayLayers = getConfigMapEnabledOverlayLayers();
+ if(!enabledOverlayLayers.includes(layerName)){
+ enabledOverlayLayers.push(layerName);
+ }
+ setConfigMapEnabledOverlayLayers(enabledOverlayLayers);
+
+ // clear traceroutes layer when traceroutes overlay is added
+ if (layerName === "Traceroutes") {
+ traceroutesLayerGroup.clearLayers();
+ }
+});
+
+// update config when map overlay is removed
+map.on('overlayremove', function(event) {
+ const layerName = event.name;
+ const enabledOverlayLayers = getConfigMapEnabledOverlayLayers().filter(function(enabledOverlayLayer) {
+ return enabledOverlayLayer !== layerName;
+ });
+ setConfigMapEnabledOverlayLayers(enabledOverlayLayers);
+});
+
+// handle map clicks
+map.on('click', function() {
+
+ // remove outline when map clicked
+ clearNodeOutline();
+
+ // send callback to vue
+ window._onMapClick();
+
+});
+
+// close all tooltips and popups when clicking map
+map.on("click", function(event) {
+
+ // do nothing when clicking inside tooltip
+ const clickedElement = event.originalEvent.target;
+ if(elementOrAnyAncestorHasClass(clickedElement, "leaflet-tooltip")){
+ return;
+ }
+
+ closeAllTooltips();
+ closeAllPopups();
+
+});
+
+function isValidLatLng(lat, lng) {
+
+ if(isNaN(lat) || isNaN(lng)){
+ return false;
+ }
+
+ return true;
+
+}
+
+function findNodeById(id) {
+
+ // find node by id
+ var node = nodes.find((node) => node.node_id.toString() === id.toString());
+ if(node){
+ return node;
+ }
+
+ return null;
+
+}
+
+function findNodeMarkerById(id) {
+
+ // find node marker by id
+ var nodeMarker = nodeMarkers[id];
+ if(nodeMarker){
+ return nodeMarker;
+ }
+
+ return null;
+
+}
+
+function goToNode(id, animate, zoom){
+
+ // find node
+ var node = findNodeById(id);
+ if(!node){
+ alert("Could not find node: " + id);
+ return false;
+ }
+
+ // find node marker by id
+ var nodeMarker = findNodeMarkerById(id);
+ if(!nodeMarker){
+ return false;
+ }
+
+ // close all popups and tooltips
+ closeAllPopups();
+ closeAllTooltips();
+
+ // select node
+ showNodeOutline(id);
+
+ // fly to node marker
+ const shouldAnimate = animate != null ? animate : true;
+ map.flyTo(nodeMarker.getLatLng(), zoom || getConfigZoomLevelGoToNode(), {
+ animate: getConfigEnableMapAnimations() ? shouldAnimate : false,
+ });
+
+ // open tooltip for node
+ map.openTooltip(getTooltipContentForNode(node), nodeMarker.getLatLng(), {
+ interactive: true, // allow clicking buttons inside tooltip
+ permanent: true, // don't auto dismiss when clicking buttons inside tooltip
+ });
+
+ // successfully went to node
+ return true;
+
+}
+
+function goToRandomNode() {
+ if(nodes.length > 0){
+ const randomNode = nodes[Math.floor(Math.random() * nodes.length)];
+ if(randomNode){
+
+ // go to node
+ if(window.goToNode(randomNode.node_id)){
+ return;
+ }
+
+ // fallback to showing node details since we can't go to the node
+ window.showNodeDetails(randomNode.node_id);
+
+ }
+ }
+}
+
+function clearAllNodes() {
+ nodesLayerGroup.clearLayers();
+ nodesClusteredLayerGroup.clearLayers();
+ nodesRouterLayerGroup.clearLayers();
+ nodesBackboneLayerGroup.clearLayers();
+ nodesShortSlowLayerGroup.clearLayers();
+ //nodesMediumFastLayerGroup.clearLayers();
+ nodesLongFastLayerGroup.clearLayers();
+}
+
+function clearAllBackboneConnections() {
+ backboneConnectionsLayerGroup.clearLayers();
+}
+
+function clearAllWaypoints() {
+ waypointsLayerGroup.clearLayers();
+}
+
+function clearAllTraceroutes() {
+ traceroutesLayerGroup.clearLayers();
+}
+
+function clearAllConnections() {
+ connectionsLayerGroup.clearLayers();
+ backboneConnectionsLayerGroup.clearLayers();
+}
+
+function closeAllPopups() {
+ map.eachLayer(function(layer) {
+ if(layer.options.pane === "popupPane"){
+ layer.removeFrom(map);
+ }
+ });
+}
+
+function closeAllTooltips() {
+ map.eachLayer(function(layer) {
+ if(layer.options.pane === "tooltipPane"){
+ layer.removeFrom(map);
+ }
+ });
+}
+
+function clearAllPositionHistory() {
+ nodePositionHistoryLayerGroup.clearLayers();
+}
+
+function clearNodeOutline() {
+ if(selectedNodeOutlineCircle){
+ selectedNodeOutlineCircle.removeFrom(map);
+ selectedNodeOutlineCircle = null;
+ }
+}
+
+function showNodeOutline(id) {
+
+ // remove any existing node circle
+ clearNodeOutline();
+
+ // find node marker by id
+ const nodeMarker = nodeMarkers[id];
+ if(!nodeMarker){
+ return;
+ }
+
+ // find node by id
+ const node = findNodeById(id);
+ if(!node){
+ return;
+ }
+
+ // add position precision circle around node
+ if(node.position_precision != null && node.position_precision > 0 && node.position_precision < 32){
+ selectedNodeOutlineCircle = L.circle(nodeMarker.getLatLng(), {
+ radius: getPositionPrecisionInMeters(node.position_precision),
+ }).addTo(map);
+ }
+
+}
+
+function showNodeDetails(id) {
+
+ // find node
+ const node = findNodeById(id);
+ if(!node){
+ return;
+ }
+
+ // fire callback to vuejs handler
+ window._onNodeClick(node);
+
+}
+
+function getColourForSnr(snr) {
+ if(snr >= -4) return "#16a34a"; // good
+ if(snr > -8) return "#fff200"; // medium-good
+ if(snr > -12) return "#ff9f1c"; // medium
+ return "#dc2626"; // bad
+}
+
+function getSignalBarsIndicator(snrDb) {
+ if(snrDb == null) return '';
+
+ // Determine number of bars based on SNR
+ let bars = 0;
+ if(snrDb >= -4) bars = 4; // good
+ else if(snrDb > -8) bars = 3; // medium-good
+ else if(snrDb > -12) bars = 2; // medium
+ else bars = 1; // bad
+
+ const color = getColourForSnr(snrDb);
+
+ // Create 4 bars with increasing height
+ let indicator = '';
+
+ // Bar heights: 4px, 6px, 8px, 10px
+ const barHeights = [4, 6, 8, 10];
+ const barWidth = 2;
+
+ for (let i = 0; i < 4; i++) {
+ const height = barHeights[i];
+ const isActive = i < bars;
+ const barColor = isActive ? color : '#d1d5db'; // gray for inactive bars
+ indicator += ` `;
+ }
+
+ indicator += ' ';
+ return indicator;
+}
+
+function cleanUpNodeConnections() {
+
+ // close tooltips and popups
+ closeAllPopups();
+ closeAllTooltips();
+
+ // setup node connections layer
+ nodeConnectionsLayerGroup.clearLayers();
+ nodeConnectionsLayerGroup.removeFrom(map);
+ nodeConnectionsLayerGroup.addTo(map);
+
+}
+
+function getTerrainProfileImage(node1, node2) {
+
+ // line colour between nodes
+ const lineColour = "0000FF"; // blue
+
+ // node 1 (left side of image)
+ const node1MarkerColour = "0000FF"; // blue
+ const node1Latitude = node1.latitude;
+ const node1Longitude = node1.longitude;
+ const node1ElevationMSL = node1.altitude ?? "";
+
+ // node 2 (right side of image)
+ const node2MarkerColour = "0000FF"; // blue
+ const node2Latitude = node2.latitude;
+ const node2Longitude = node2.longitude;
+ const node2ElevationMSL = node2.altitude ?? "";
+
+ // generate terrain profile image url
+ return "https://heywhatsthat.com/bin/profile-0904.cgi?" + new URLSearchParams({
+ src: "meshtastic.liamcottle.net",
+ axes: 1, // include grid lines and a scale
+ metric: 1, // show metric units
+ curvature: 1,
+ width: 500,
+ height: 200,
+ pt0: `${node1Latitude},${node1Longitude},${lineColour},${node1ElevationMSL},${node1MarkerColour}`,
+ pt1: `${node2Latitude},${node2Longitude},${lineColour},${node2ElevationMSL},${node2MarkerColour}`,
+ }).toString();
+
+}
+
+async function showNodeConnections(id) {
+
+ cleanUpNodeConnections();
+
+ // find node
+ const node = findNodeById(id);
+ if(!node){
+ return;
+ }
+
+ // find node marker
+ const nodeMarker = findNodeMarkerById(node.node_id);
+ if(!nodeMarker){
+ return;
+ }
+
+ // show overlay for node connections
+ window._onShowNodeConnectionsClick(node);
+
+ // Fetch connections for this node
+ const connectionsTimePeriodSec = getConfigConnectionsTimePeriodInSeconds();
+ const connectionsTimeFrom = connectionsTimePeriodSec ? (Date.now() - connectionsTimePeriodSec * 1000) : undefined;
+ const connectionsParams = new URLSearchParams();
+ connectionsParams.set('node_id', id);
+ if (connectionsTimeFrom) connectionsParams.set('time_from', connectionsTimeFrom);
+
+ try {
+ const response = await window.axios.get(`/api/v1/connections?${connectionsParams.toString()}`);
+ const connections = response.data.connections ?? [];
+
+ for (const connection of connections) {
+ // Convert to numbers for comparison since API returns strings
+ const nodeA = parseInt(connection.node_a, 10);
+ const nodeB = parseInt(connection.node_b, 10);
+
+ const otherNodeId = nodeA === id ? nodeB : nodeA;
+ const otherNode = findNodeById(otherNodeId);
+ const otherNodeMarker = findNodeMarkerById(otherNodeId);
+
+ if (!otherNode || !otherNodeMarker) continue;
+
+ // Apply bidirectional filter
+ const configConnectionsBidirectionalOnly = getConfigConnectionsBidirectionalOnly();
+ if(configConnectionsBidirectionalOnly){
+ const hasDirectionAB = connection.direction_ab && connection.direction_ab.avg_snr_db != null;
+ const hasDirectionBA = connection.direction_ba && connection.direction_ba.avg_snr_db != null;
+ if(!hasDirectionAB || !hasDirectionBA){
+ continue;
+ }
+ }
+
+ // Apply minimum SNR filter
+ const configConnectionsMinSnrDb = getConfigConnectionsMinSnrDb();
+ if(configConnectionsMinSnrDb != null){
+ const snrAB = connection.direction_ab && connection.direction_ab.avg_snr_db != null ? connection.direction_ab.avg_snr_db : null;
+ const snrBA = connection.direction_ba && connection.direction_ba.avg_snr_db != null ? connection.direction_ba.avg_snr_db : null;
+
+ const configConnectionsBidirectionalMinSnr = getConfigConnectionsBidirectionalMinSnr();
+ let hasSnrAboveThreshold;
+
+ if(configConnectionsBidirectionalMinSnr){
+ // Bidirectional mode: ALL existing directions must meet threshold
+ const directionsToCheck = [];
+ if(snrAB != null) directionsToCheck.push(snrAB);
+ if(snrBA != null) directionsToCheck.push(snrBA);
+
+ if(directionsToCheck.length === 0){
+ // No SNR data in either direction, skip
+ hasSnrAboveThreshold = false;
+ } else {
+ // All existing directions must be above threshold
+ hasSnrAboveThreshold = directionsToCheck.every(snr => snr > configConnectionsMinSnrDb);
+ }
+ } else {
+ // Default mode: EITHER direction has SNR above threshold
+ hasSnrAboveThreshold = (snrAB != null && snrAB > configConnectionsMinSnrDb) || (snrBA != null && snrBA > configConnectionsMinSnrDb);
+ }
+
+ if(!hasSnrAboveThreshold){
+ continue;
+ }
+ }
+
+ // Calculate distance
+ const distanceInMeters = nodeMarker.getLatLng().distanceTo(otherNodeMarker.getLatLng()).toFixed(2);
+ const configConnectionsMaxDistanceInMeters = getConfigConnectionsMaxDistanceInMeters();
+ if(configConnectionsMaxDistanceInMeters != null && parseFloat(distanceInMeters) > configConnectionsMaxDistanceInMeters){
+ continue;
+ }
+
+ let distance = `${distanceInMeters} meters`;
+ if (distanceInMeters >= 1000) {
+ const distanceInKilometers = (distanceInMeters / 1000).toFixed(2);
+ distance = `${distanceInKilometers} kilometers`;
+ }
+
+ // Determine line color
+ const configConnectionsColoredLines = getConfigConnectionsColoredLines();
+ const worstSnrDb = connection.worst_avg_snr_db;
+ const lineColor = configConnectionsColoredLines && worstSnrDb != null ? getColourForSnr(worstSnrDb) : '#2563eb';
+
+ // Create bidirectional line
+ const line = L.polyline([
+ nodeMarker.getLatLng(),
+ otherNodeMarker.getLatLng(),
+ ], {
+ color: lineColor,
+ opacity: 0.75,
+ weight: 3,
+ }).addTo(nodeConnectionsLayerGroup);
+
+ // Generate tooltip using standardized function
+ const tooltipNodeA = findNodeById(connection.node_a);
+ const tooltipNodeB = findNodeById(connection.node_b);
+ const tooltip = generateConnectionTooltip(connection, tooltipNodeA, tooltipNodeB, distance);
+
+ line.bindTooltip(tooltip, {
+ sticky: true,
+ opacity: 1,
+ interactive: true,
+ })
+ .bindPopup(tooltip)
+ .on('click', function(event) {
+ event.target.closeTooltip();
+ });
+ }
+ } catch (err) {
+ console.error('Error fetching connections:', err);
+ }
+
+}
+
+function clearMap() {
+ closeAllPopups();
+ closeAllTooltips();
+ clearAllNodes();
+ clearAllBackboneConnections();
+ clearAllWaypoints();
+ clearAllTraceroutes();
+ clearAllConnections();
+ clearNodeOutline();
+ cleanUpNodeConnections();
+}
+
+// returns true if the element or one of its parents has the class classname
+function elementOrAnyAncestorHasClass(element, className) {
+
+ // check if element contains class
+ if(element.classList && element.classList.contains(className)){
+ return true;
+ }
+
+ // check if parent node has the class
+ if(element.parentNode){
+ return elementOrAnyAncestorHasClass(element.parentNode, className);
+ }
+
+ // couldn't find the class
+ return false;
+
+}
+
+// escape strings for tooltips etc, to prevent html/script injection
+// not used in vuejs, as that auto escapes
+function escapeString(string) {
+ return string.replace(//g, ">");
+}
+
+
+function onNodesUpdated(updatedNodes) {
+
+ // clear nodes cache
+ nodes = [];
+
+ // get config
+ const now = moment();
+ const configNodesMaxAgeInSeconds = getConfigNodesMaxAgeInSeconds();
+ const configNodesOfflineAgeInSeconds = getConfigNodesOfflineAgeInSeconds();
+ const configConnectionsMaxDistanceInMeters = getConfigConnectionsMaxDistanceInMeters();
+
+ // add nodes
+ for(const node of updatedNodes){
+
+ // skip nodes older than configured node max age
+ if(configNodesMaxAgeInSeconds){
+ const lastUpdatedAgeInMillis = now.diff(moment(node.updated_at));
+ if(lastUpdatedAgeInMillis > configNodesMaxAgeInSeconds * 1000){
+ continue;
+ }
+ }
+
+ // add to cache
+ nodes.push(node);
+
+ // skip nodes without position
+ if(!node.latitude || !node.longitude){
+ continue;
+ }
+
+ // fix lat long
+ node.latitude = node.latitude / 10000000;
+ node.longitude = node.longitude / 10000000;
+
+ // skip nodes with invalid position
+ if(!isValidLatLng(node.latitude, node.longitude)){
+ continue;
+ }
+
+ // wrap longitude for shortest path, everything to left of australia should be shown on the right
+ var longitude = parseFloat(node.longitude);
+ if(longitude <= 100){
+ longitude += 360;
+ }
+
+ // icon based on channel preset
+ var icon = iconLongFast;
+
+ if (node.channel_id == "ShortSlow") {
+ icon = iconShortSlow;
+ }
+
+ /*if (node.channel_id == "MediumFast") {
+ icon = iconMediumFast;
+ }*/
+
+ // use offline icon for nodes older than configured node offline age
+ if(configNodesOfflineAgeInSeconds){
+ const lastUpdatedAgeInMillis = now.diff(moment(node.updated_at));
+ if(lastUpdatedAgeInMillis > configNodesOfflineAgeInSeconds * 1000){
+ icon = iconOffline;
+ }
+ }
+
+ // determine zIndexOffset: MediumFast (1000), LongFast (-1000), Offline (-2000)
+ var zIndexOffset = 1000;
+ if(icon == iconOffline){
+ zIndexOffset = -2000;
+ } else if(node.channel_id == 'LongFast'){
+ zIndexOffset = -1000;
+ }
+
+ // To not have overlapping nodes.
+ var latJitter = 0;
+ var lonJitter = 0;
+ // If position pression > 45m apply random jitter within a small circle to avoid diagonal-only displacement
+ if (node.position_precision < 19) {
+ const maxMeters = 40;
+ const r = maxMeters * Math.sqrt(Math.random());
+ const theta = 2 * Math.PI * Math.random();
+ const dy = r * Math.sin(theta);
+ const dx = r * Math.cos(theta);
+ const metersPerDegLat = 111320;
+ const metersPerDegLon = 111320 * Math.cos(node.latitude * Math.PI / 180);
+ latJitter = dy / metersPerDegLat;
+ lonJitter = metersPerDegLon ? (dx / metersPerDegLon) : 0;
+ }
+
+ // create node marker
+ const marker = L.marker([node.latitude + latJitter, longitude + lonJitter], {
+ icon: icon,
+ tagName: node.node_id,
+ // zIndex: offline (-2000) < has channel_id (-1000) < others (1000)
+ zIndexOffset: zIndexOffset,
+ }).on('click', function(event) {
+ // close tooltip on click to prevent tooltip and popup showing at same time
+ event.target.closeTooltip();
+ });
+
+ // add marker to node layer groups
+ marker.addTo(nodesLayerGroup);
+ nodesClusteredLayerGroup.addLayer(marker);
+
+ // add markers for routers and repeaters to routers layer group
+ if(node.role_name === "ROUTER"
+ || node.role_name === "ROUTER_CLIENT"
+ || node.role_name === "ROUTER_LATE"
+ || node.role_name === "REPEATER"){
+ nodesRouterLayerGroup.addLayer(marker);
+ }
+
+ // add markers for backbone to layer group
+ if(node.is_backbone) {
+ nodesBackboneLayerGroup.addLayer(marker);
+ }
+
+ if(node.channel_id == "ShortSlow") {
+ nodesShortSlowLayerGroup.addLayer(marker);
+ }
+
+ // add markers for MediumFast channel to layer group
+ /*if(node.channel_id == "MediumFast") {
+ nodesMediumFastLayerGroup.addLayer(marker);
+ }*/
+
+ // add markers for LongFast channel to layer group
+ if(node.channel_id == "LongFast") {
+ nodesLongFastLayerGroup.addLayer(marker);
+ }
+
+ // show tooltip on desktop only
+ if(!isMobile()){
+ marker.bindTooltip(getTooltipContentForNode(node), {
+ interactive: true,
+ });
+ }
+
+ // show node info tooltip when clicking node marker
+ marker.on("click", function(event) {
+
+ // close all other popups and tooltips
+ closeAllTooltips();
+ closeAllPopups();
+
+ // find node
+ const node = findNodeById(event.target.options.tagName);
+ if(!node){
+ return;
+ }
+
+ // show position precision outline
+ showNodeOutline(node.node_id);
+
+ // open tooltip for node
+ map.openTooltip(getTooltipContentForNode(node), event.target.getLatLng(), {
+ interactive: true, // allow clicking buttons inside tooltip
+ permanent: true, // don't auto dismiss when clicking buttons inside tooltip
+ });
+
+ });
+
+ // add to cache
+ nodeMarkers[node.node_id] = marker;
+
+ }
+
+ window._onNodesUpdated(nodes);
+
+}
+
+function onWaypointsUpdated(updatedWaypoints) {
+
+ // clear nodes cache
+ waypoints = [];
+
+ // get config
+ const now = moment();
+ const configWaypointsMaxAgeInSeconds = getConfigWaypointsMaxAgeInSeconds();
+
+ // add nodes
+ for(const waypoint of updatedWaypoints){
+
+ // skip waypoints older than configured waypoint max age
+ if(configWaypointsMaxAgeInSeconds){
+ const lastUpdatedAgeInMillis = now.diff(moment(waypoint.updated_at));
+ if(lastUpdatedAgeInMillis > configWaypointsMaxAgeInSeconds * 1000){
+ continue;
+ }
+ }
+
+ // skip expired waypoints
+ if(waypoint.expire < Date.now() / 1000){
+ continue;
+ }
+
+ // skip waypoints without position
+ if(!waypoint.latitude || !waypoint.longitude){
+ continue;
+ }
+
+ // fix lat long
+ waypoint.latitude = waypoint.latitude / 10000000;
+ waypoint.longitude = waypoint.longitude / 10000000;
+
+ // skip waypoints with invalid position
+ if(!isValidLatLng(waypoint.latitude, waypoint.longitude)){
+ continue;
+ }
+
+ // wrap longitude for shortest path, everything to left of australia should be shown on the right
+ var longitude = parseFloat(waypoint.longitude);
+ if(longitude <= 100){
+ longitude += 360;
+ }
+
+ // determine emoji to show as marker icon
+ const emoji = waypoint.icon === 0 ? 128205 : waypoint.icon;
+ const emojiText = String.fromCodePoint(emoji)
+
+ var tooltip = getTooltipContentForWaypoint(waypoint);
+
+ // create waypoint marker
+ const marker = L.marker([waypoint.latitude, longitude], {
+ icon: L.divIcon({
+ className: 'waypoint-label',
+ iconSize: [26, 26], // increase from 12px to 26px
+ html: emojiText,
+ }),
+ }).bindPopup(tooltip).on('click', function(event) {
+ // close tooltip on click to prevent tooltip and popup showing at same time
+ event.target.closeTooltip();
+ });
+
+ // show tooltip on desktop only
+ if(!isMobile()){
+ marker.bindTooltip(tooltip, {
+ interactive: true,
+ });
+ }
+
+ // add marker to waypoints layer groups
+ marker.addTo(waypointsLayerGroup);
+
+ // add to cache
+ waypoints.push(waypoint);
+
+ }
+
+}
+
+
+function generateConnectionTooltip(connection, nodeA, nodeB, distance) {
+ let tooltip = `Connection `;
+ tooltip += ` [${escapeString(nodeA.short_name)}] ${escapeString(nodeA.long_name)} <-> [${escapeString(nodeB.short_name)}] ${escapeString(nodeB.long_name)}`;
+ tooltip += ` Distance: ${distance}`;
+ tooltip += ` `;
+
+ // Direction A -> B
+ if (connection.direction_ab.avg_snr_db != null) {
+ tooltip += `${escapeString(nodeA.short_name)} -> ${escapeString(nodeB.short_name)}: `;
+ tooltip += ` SNR: ${connection.direction_ab.avg_snr_db.toFixed(1)}dB ${getSignalBarsIndicator(connection.direction_ab.avg_snr_db)} (Average of ${connection.direction_ab.total_count} edges)`;
+ if (connection.direction_ab.last_5_edges.length > 0) {
+ tooltip += ` Last 5 edges:`;
+ for (const edge of connection.direction_ab.last_5_edges) {
+ const timeAgo = moment(new Date(edge.created_at)).fromNow();
+ const sourceIcon = edge.source === "TRACEROUTE_APP" ? "⇵" : (edge.source === "NEIGHBORINFO_APP" ? "✳" : "?");
+ tooltip += ` ${edge.snr_db.toFixed(1)}dB ${getSignalBarsIndicator(edge.snr_db)} (${timeAgo} by:${sourceIcon})`;
+ }
+ } else {
+ tooltip += ` No recent edges`;
+ }
+ }
+
+ // Direction B -> A
+ if (connection.direction_ba.avg_snr_db != null) {
+ tooltip += `${escapeString(nodeB.short_name)} -> ${escapeString(nodeA.short_name)}: `;
+ tooltip += ` SNR: ${connection.direction_ba.avg_snr_db.toFixed(1)}dB ${getSignalBarsIndicator(connection.direction_ba.avg_snr_db)} (Average of ${connection.direction_ba.total_count} edges)`;
+ if (connection.direction_ba.last_5_edges.length > 0) {
+ tooltip += ` Last 5 edges:`;
+ for (const edge of connection.direction_ba.last_5_edges) {
+ const timeAgo = moment(new Date(edge.created_at)).fromNow();
+ const sourceIcon = edge.source === "TRACEROUTE_APP" ? "⇵" : (edge.source === "NEIGHBORINFO_APP" ? "✳" : "?");
+ tooltip += ` ${edge.snr_db.toFixed(1)}dB ${getSignalBarsIndicator(edge.snr_db)} (${timeAgo} by:${sourceIcon})`;
+ }
+ } else {
+ tooltip += ` No recent edges`;
+ }
+ }
+
+ // Add terrain profile image
+ const terrainImageUrl = getTerrainProfileImage(nodeA, nodeB);
+ tooltip += ` Terrain images from HeyWhatsThat.com `;
+ tooltip += ` `;
+
+ return tooltip;
+}
+
+function onConnectionsUpdated(connections) {
+ // Clear existing connections
+ clearAllConnections();
+
+ for (const connection of connections) {
+ // Find both node markers
+ const nodeAMarker = findNodeMarkerById(connection.node_a);
+ const nodeBMarker = findNodeMarkerById(connection.node_b);
+
+ // Skip if either node marker doesn't exist
+ if (!nodeAMarker || !nodeBMarker) {
+ continue;
+ }
+
+ // Find node objects for names and terrain profile
+ const nodeA = findNodeById(connection.node_a);
+ const nodeB = findNodeById(connection.node_b);
+
+ if (!nodeA || !nodeB) {
+ continue;
+ }
+
+ // Apply bidirectional filter
+ const configConnectionsBidirectionalOnly = getConfigConnectionsBidirectionalOnly();
+ if(configConnectionsBidirectionalOnly){
+ const hasDirectionAB = connection.direction_ab && connection.direction_ab.avg_snr_db != null;
+ const hasDirectionBA = connection.direction_ba && connection.direction_ba.avg_snr_db != null;
+ if(!hasDirectionAB || !hasDirectionBA){
+ continue;
+ }
+ }
+
+ // Apply minimum SNR filter
+ const configConnectionsMinSnrDb = getConfigConnectionsMinSnrDb();
+ if(configConnectionsMinSnrDb != null){
+ const snrAB = connection.direction_ab && connection.direction_ab.avg_snr_db != null ? connection.direction_ab.avg_snr_db : null;
+ const snrBA = connection.direction_ba && connection.direction_ba.avg_snr_db != null ? connection.direction_ba.avg_snr_db : null;
+
+ const configConnectionsBidirectionalMinSnr = getConfigConnectionsBidirectionalMinSnr();
+ let hasSnrAboveThreshold;
+
+ if(configConnectionsBidirectionalMinSnr){
+ // Bidirectional mode: ALL existing directions must meet threshold
+ const directionsToCheck = [];
+ if(snrAB != null) directionsToCheck.push(snrAB);
+ if(snrBA != null) directionsToCheck.push(snrBA);
+
+ if(directionsToCheck.length === 0){
+ // No SNR data in either direction, skip
+ hasSnrAboveThreshold = false;
+ } else {
+ // All existing directions must be above threshold
+ hasSnrAboveThreshold = directionsToCheck.every(snr => snr > configConnectionsMinSnrDb);
+ }
+ } else {
+ // Default mode: EITHER direction has SNR above threshold
+ hasSnrAboveThreshold = (snrAB != null && snrAB > configConnectionsMinSnrDb) || (snrBA != null && snrBA > configConnectionsMinSnrDb);
+ }
+
+ if(!hasSnrAboveThreshold){
+ continue;
+ }
+ }
+
+ // Calculate distance between nodes
+ const distanceInMeters = nodeAMarker.getLatLng().distanceTo(nodeBMarker.getLatLng()).toFixed(2);
+
+ // Apply distance filter
+ const configConnectionsMaxDistanceInMeters = getConfigConnectionsMaxDistanceInMeters();
+ if(configConnectionsMaxDistanceInMeters != null && parseFloat(distanceInMeters) > configConnectionsMaxDistanceInMeters){
+ continue;
+ }
+
+ let distance = `${distanceInMeters} meters`;
+ if (distanceInMeters >= 1000) {
+ const distanceInKilometers = (distanceInMeters / 1000).toFixed(2);
+ distance = `${distanceInKilometers} kilometers`;
+ }
+
+ // Determine line color based on worst average SNR (if colored lines enabled)
+ const configConnectionsColoredLines = getConfigConnectionsColoredLines();
+ const worstSnrDb = connection.worst_avg_snr_db;
+ const lineColor = configConnectionsColoredLines && worstSnrDb != null ? getColourForSnr(worstSnrDb) : '#2563eb';
+
+ // Create polyline (bidirectional, no arrows)
+ const line = L.polyline([
+ nodeAMarker.getLatLng(),
+ nodeBMarker.getLatLng(),
+ ], {
+ color: lineColor,
+ opacity: 0.75,
+ weight: 3,
+ }).addTo(connectionsLayerGroup);
+
+ // Generate tooltip
+ const tooltip = generateConnectionTooltip(connection, nodeA, nodeB, distance);
+
+ // Bind tooltip and popup
+ line.bindTooltip(tooltip, {
+ sticky: true,
+ opacity: 1,
+ interactive: true,
+ })
+ .bindPopup(tooltip)
+ .on('click', function(event) {
+ // close tooltip on click to prevent tooltip and popup showing at same time
+ event.target.closeTooltip();
+ });
+
+ // If both nodes are backbone nodes, also add to backbone layer group
+ if (nodeA.is_backbone && nodeB.is_backbone) {
+ const backboneLine = L.polyline([
+ nodeAMarker.getLatLng(),
+ nodeBMarker.getLatLng(),
+ ], {
+ color: lineColor,
+ opacity: 0.75,
+ weight: 3,
+ }).addTo(backboneConnectionsLayerGroup);
+
+ backboneLine.bindTooltip(tooltip, {
+ sticky: true,
+ opacity: 1,
+ interactive: true,
+ })
+ .bindPopup(tooltip)
+ .on('click', function(event) {
+ event.target.closeTooltip();
+ });
+ }
+ }
+}
+
+function onPositionHistoryUpdated(updatedPositionHistories) {
+
+ let positionHistoryLinesCords = [];
+
+ // add nodes
+ for(const positionHistory of updatedPositionHistories) {
+
+ // skip position history without position
+ if(!positionHistory.latitude || !positionHistory.longitude){
+ continue;
+ }
+
+ // find node this position is for
+ const node = findNodeById(positionHistory.node_id);
+ if(!node){
+ continue;
+ }
+
+ // fix lat long
+ positionHistory.latitude = positionHistory.latitude / 10000000;
+ positionHistory.longitude = positionHistory.longitude / 10000000;
+
+ // skip position history with invalid position
+ if(!isValidLatLng(positionHistory.latitude, positionHistory.longitude)){
+ continue;
+ }
+
+ // wrap longitude for shortest path, everything to left of australia should be shown on the right
+ var longitude = parseFloat(positionHistory.longitude);
+ if(longitude <= 100){
+ longitude += 360;
+ }
+
+ positionHistoryLinesCords.push([positionHistory.latitude, longitude]);
+
+ let tooltip = "";
+ if(positionHistory.type === "position"){
+ tooltip += `Position `;
+ } else if(positionHistory.type === "map_report"){
+ tooltip += `Map Report `;
+ }
+ tooltip += ` [${escapeString(node.short_name)}] ${escapeString(node.long_name)}`;
+ tooltip += ` ${positionHistory.latitude}, ${positionHistory.longitude}`;
+ tooltip += ` Heard on: ${moment(new Date(positionHistory.created_at)).format("YYYY-MM-DD HH:mm")}`;
+
+ // add gateway info if available
+ if(positionHistory.gateway_id){
+ const gatewayNode = findNodeById(positionHistory.gateway_id);
+ const gatewayNodeInfo = gatewayNode ? `[${gatewayNode.short_name}] ${gatewayNode.long_name}` : "???";
+ tooltip += ` Heard by: ${gatewayNodeInfo} `;
+ }
+
+ // create position history marker
+ const marker = L.marker([positionHistory.latitude, longitude],{
+ icon: iconPositionHistory,
+ }).bindTooltip(tooltip).bindPopup(tooltip).on('click', function(event) {
+ // close tooltip on click to prevent tooltip and popup showing at same time
+ event.target.closeTooltip();
+ });
+
+ // add marker to position history layer group
+ marker.addTo(nodePositionHistoryLayerGroup);
+
+ }
+
+ // show lines between position history markers
+ L.polyline(positionHistoryLinesCords, {
+ color: "#a855f7",
+ opacity: 1,
+ }).addTo(nodePositionHistoryLayerGroup);
+
+}
+
+function cleanUpPositionHistory() {
+
+ // close tooltips and popups
+ closeAllPopups();
+ closeAllTooltips();
+
+ // setup node position history layer
+ nodePositionHistoryLayerGroup.clearLayers();
+ nodePositionHistoryLayerGroup.removeFrom(map);
+ nodePositionHistoryLayerGroup.addTo(map);
+
+}
+
+function setLoading(loading){
+ var reloadButton = document.getElementById("reload-button");
+ if(loading){
+ reloadButton.classList.add("animate-spin");
+ } else {
+ reloadButton.classList.remove("animate-spin");
+ }
+}
+
+async function reload(goToNodeId, zoom) {
+
+ // show loading
+ setLoading(true);
+
+ // clear previous data
+ clearMap();
+
+ // fetch nodes
+ await window.axios.get('/api/v1/nodes').then(async (response) => {
+
+ // update nodes
+ onNodesUpdated(response.data.nodes);
+
+ // hide loading
+ setLoading(false);
+
+ // go to node id if provided
+ if(goToNodeId){
+
+ // go to node
+ if(window.goToNode(goToNodeId, false, zoom)){
+ return;
+ }
+
+ // fallback to showing node details since we can't go to the node
+ window.showNodeDetails(goToNodeId);
+
+ }
+
+ });
+
+ // fetch waypoints (after awaiting nodes, so we can use nodes cache in waypoint tooltips)
+ await window.axios.get('/api/v1/waypoints').then(async (response) => {
+ onWaypointsUpdated(response.data.waypoints);
+ });
+
+ // fetch connections (edges)
+ const connectionsTimePeriodSec = getConfigConnectionsTimePeriodInSeconds();
+ const connectionsTimeFrom = connectionsTimePeriodSec ? (Date.now() - connectionsTimePeriodSec * 1000) : undefined;
+ const connectionsParams = new URLSearchParams();
+ if (connectionsTimeFrom) connectionsParams.set('time_from', connectionsTimeFrom);
+ await window.axios.get(`/api/v1/connections?${connectionsParams.toString()}`).then(async (response) => {
+ onConnectionsUpdated(response.data.connections ?? []);
+ }).catch(() => {
+ onConnectionsUpdated([]);
+ });
+
+}
+
+function getRegionFrequencyRange(regionName) {
+
+ // determine lora frequency range based on region_name
+ // https://github.com/meshtastic/firmware/blob/a4c22321fca6fc8da7bab157c3812055603512ba/src/mesh/RadioInterface.cpp#L21
+ const regionNameToLoraFrequencyRange = {
+ "US": "902-928 MHz",
+ "EU_433": "433-434 MHz",
+ "EU_868": "869.4-869.65 MHz",
+ "CN": "470-510 MHz",
+ "JP": "920.8-927.8 MHz",
+ "ANZ": "915-928 MHz",
+ "RU": "868.7-869.2 MHz",
+ "KR": "920-923 MHz",
+ "TW": "920-925 MHz",
+ "IN": "865-867 MHz",
+ "NZ_865": "864-868 MHz",
+ "TH": "920-925 MHz",
+ "UA_433": "433-434.7 MHz",
+ "UA_868": "868-868.6 MHz",
+ "MY_433": "433-435 MHz",
+ "MY_919": "919-924 MHz",
+ "SG_923": "917-925 MHz",
+ "LORA_24": "2.4-2.4835 GHz",
+ "UNSET": "902-928 MHz",
+ }
+
+ return regionNameToLoraFrequencyRange[regionName] ?? null;
+
+}
+
+function getPositionPrecisionInMeters(positionPrecision) {
+ switch(positionPrecision){
+ case 2: return 5976446;
+ case 3: return 2988223;
+ case 4: return 1494111;
+ case 5: return 747055;
+ case 6: return 373527;
+ case 7: return 186763;
+ case 8: return 93381;
+ case 9: return 46690;
+ case 10: return 23345;
+ case 11: return 11672; // Android LOW_PRECISION
+ case 12: return 5836;
+ case 13: return 2918;
+ case 14: return 1459;
+ case 15: return 729;
+ case 16: return 364; // Android MED_PRECISION
+ case 17: return 182;
+ case 18: return 91;
+ case 19: return 45;
+ case 20: return 22;
+ case 21: return 11;
+ case 22: return 5;
+ case 23: return 2;
+ case 24: return 1;
+ case 32: return 0; // Android HIGH_PRECISION
+ }
+ return null;
+}
+
+function formatPositionPrecision(positionPrecision) {
+
+ // get position precision in meters
+ const positionPrecisionInMeters = getPositionPrecisionInMeters(positionPrecision);
+ if(positionPrecisionInMeters == null){
+ return "?";
+ }
+
+ // format kilometers
+ if(positionPrecisionInMeters > 1000){
+ const positionPrecisionInKilometers = Math.ceil(positionPrecisionInMeters / 1000);
+ return `±${positionPrecisionInKilometers}km`;
+ }
+
+ // format meters
+ return `±${positionPrecisionInMeters}m`;
+
+}
+
+function getTooltipContentForNode(node) {
+
+ var loraFrequencyRange = getRegionFrequencyRange(node.region_name);
+
+ var tooltip = ` ` +
+ `${escapeString(node.long_name)} ` +
+ ` Short Name: ${escapeString(node.short_name)}` +
+ (node.num_online_local_nodes != null ? ` Local Nodes Online: ${node.num_online_local_nodes}` : '') +
+ (node.position_precision != null && node.position_precision !== 32 ? ` Position Precision: ${formatPositionPrecision(node.position_precision)}` : '') +
+ ` Role: ${node.role_name}` +
+ ` Hardware: ${node.hardware_model_name}` +
+ (node.firmware_version != null ? ` Firmware: ${node.firmware_version}` : '') +
+ ` OK to MQTT: ${node.ok_to_mqtt}`;
+
+ if(node.battery_level){
+ if(node.battery_level > 100){
+ tooltip += ` Battery: ${node.battery_level > 100 ? 'Plugged In' : node.battery_level}`;
+ } else {
+ tooltip += ` Battery: ${node.battery_level}%`;
+ }
+ }
+
+ if(node.voltage){
+ tooltip += ` Voltage: ${Number(node.voltage).toFixed(2)}V`;
+ }
+
+ if(node.channel_utilization){
+ tooltip += ` Ch Util: ${Number(node.channel_utilization).toFixed(2)}%`;
+ }
+
+ if(node.air_util_tx){
+ tooltip += ` Air Util: ${Number(node.air_util_tx).toFixed(2)}%`;
+ }
+
+ // ignore alt above 42949000 due to https://github.com/meshtastic/firmware/issues/3109
+ if(node.altitude && node.altitude < 42949000){
+ tooltip += ` Altitude: ${node.altitude}m`;
+ }
+
+ // bottom info
+ tooltip += ` ID: ${node.node_id}`;
+ tooltip += ` Hex ID: ${node.node_id_hex}`;
+ tooltip += ` Updated: ${moment(new Date(node.updated_at)).fromNow()}`;
+ tooltip += (node.mqtt_connection_state_updated_at ? ` MQTT Updated: ${moment(new Date(node.mqtt_connection_state_updated_at)).fromNow()}` : '');
+ tooltip += (node.neighbours_updated_at ? ` Neighbours Updated: ${moment(new Date(node.neighbours_updated_at)).fromNow()}` : '');
+ tooltip += (node.position_updated_at ? ` Position Updated: ${moment(new Date(node.position_updated_at)).fromNow()}` : '');
+
+ // show details button
+ tooltip += `Show Full Details `;
+ tooltip += `Show Connections `;
+ tooltip += ``;
+
+ return tooltip;
+
+}
+
+function getTooltipContentForWaypoint(waypoint) {
+
+ // get from node name
+ var fromNode = findNodeById(waypoint.from);
+
+ var tooltip = `${escapeString(waypoint.name)} ` +
+ (waypoint.description ? ` ${escapeString(waypoint.description)}` : '') +
+ ` Expires: ${moment(new Date(waypoint.expire * 1000)).fromNow()}` +
+ ` Lat/Lng: ${waypoint.latitude}, ${waypoint.longitude}` +
+ ` From ID: ${waypoint.from}` +
+ ` From Hex ID: !${Number(waypoint.from).toString(16)}`;
+
+ // show node name this waypoint is from, if possible
+ if(fromNode != null){
+ tooltip += ` From Node: ${escapeString(fromNode.long_name) || 'Unnamed Node'} `;
+ } else {
+ tooltip += ` From Node: ???`;
+ }
+
+ // bottom info
+ tooltip += ` ID: ${waypoint.waypoint_id}`;
+ tooltip += ` Updated: ${moment(new Date(waypoint.updated_at)).fromNow()}`;
+
+ return tooltip;
+
+}
+
+window._onHideNodeConnectionsClick = function() {
+ cleanUpNodeConnections();
+};
+
+// parse url params
+var queryParams = new URLSearchParams(location.search);
+var queryNodeId = queryParams.get('node_id');
+var queryLat = queryParams.get('lat');
+var queryLng = queryParams.get('lng');
+var queryZoom = queryParams.get('zoom');
+
+// go to lat/lng if provided
+if(queryLat && queryLng){
+ const zoomLevel = queryZoom || getConfigZoomLevelGoToNode();
+ map.flyTo([queryLat, queryLng], zoomLevel, {
+ animate: false,
+ });
+}
+
+// auto update url when lat/lng/zoom changes
+map.on("moveend zoomend", function() {
+
+ // check if user enabled auto updating position in url
+ const autoUpdatePositionInUrl = getConfigAutoUpdatePositionInUrl();
+ if(!autoUpdatePositionInUrl){
+ return;
+ }
+
+ // get map info
+ const latLng = map.getCenter();
+ const zoom = map.getZoom();
+
+ // construct new url
+ const url = new URL(window.location.href);
+ url.searchParams.set("lat", latLng.lat);
+ url.searchParams.set("lng", latLng.lng);
+ url.searchParams.set("zoom", zoom);
+
+ // update current url
+ if(window.history.replaceState){
+ window.history.replaceState(null, null, url.toString());
+ }
+
+});
+
+// reload and go to provided node id
+reload(queryNodeId, queryZoom);
+
+// WebSocket connection for real-time messages
+var ws = null;
+var tracerouteCooldown = {}; // Track last traceroute time per from node (for 20s cooldown)
+var activeTracerouteKeys = new Set(); // Track active traceroute visualizations to prevent duplicates
+var tracerouteLines = {}; // Track lines for each traceroute route key for cleanup
+
+function connectWebSocket() {
+ // Determine WebSocket URL - use same hostname as current page, port 8081
+ const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ // Heuristic: if running on localhost, use port 8081; otherwise use /ws path via Nginx
+ const isLocalhost = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
+ const wsUrl = isLocalhost
+ ? `${wsProtocol}//${location.hostname}:8081`
+ : `${wsProtocol}//${location.host}/ws`;
+
+ console.log('Connecting to WebSocket:', wsUrl);
+ ws = new WebSocket(wsUrl);
+
+ ws.onopen = function() {
+ console.log('WebSocket connected');
+ };
+
+ ws.onmessage = function(event) {
+ try {
+ const message = JSON.parse(event.data);
+ handleWebSocketMessage(message);
+ } catch (e) {
+ console.error('Error parsing WebSocket message:', e);
+ }
+ };
+
+ ws.onerror = function(error) {
+ console.error('WebSocket error:', error);
+ };
+
+ ws.onclose = function() {
+ console.log('WebSocket disconnected, reconnecting in 5 seconds...');
+ setTimeout(connectWebSocket, 5000);
+ };
+}
+
+function handleWebSocketMessage(message) {
+ if (message.type === 'traceroute') {
+ handleTraceroute(message.data);
+ }
+}
+
+function handleTraceroute(data) {
+ // Only visualize traceroutes where want_response is false (the reply coming back)
+ if (data.want_response) {
+ return;
+ }
+
+ // When want_response is false, from and to are swapped from the original request
+ // The path goes from 'to' (original sender) through route to 'from' (original destination)
+ const originalSender = data.to; // This was the original sender
+ const originalDestination = data.from; // This was the original destination
+ const route = data.route || [];
+ const snrTowards = data.snr_towards || [];
+
+ // Deduplicate: ignore traceroutes from the same original sender for 20 seconds
+ const now = Date.now();
+ if (tracerouteCooldown[originalSender] && (now - tracerouteCooldown[originalSender]) < 20000) {
+ return; // Still in cooldown period
+ }
+
+ // Create unique key for this traceroute path to prevent duplicate visualizations
+ // Use original sender (to), original destination (from), and route to create unique key
+ // (ignoring gateway_id since multiple gateways can receive same route)
+ const routeKey = `${originalSender}-${originalDestination}-${route.join('-')}`;
+ if (activeTracerouteKeys.has(routeKey)) {
+ return; // Already visualizing this route
+ }
+
+ // Mark as active and set cooldown
+ activeTracerouteKeys.add(routeKey);
+ tracerouteCooldown[originalSender] = now;
+
+ // Build the complete path: to (original sender) -> route[0] -> route[1] -> ... -> from (original destination)
+ const path = [originalSender]; // Start from original sender
+ if (route.length > 0) {
+ path.push(...route);
+ }
+ path.push(originalDestination); // End at original destination
+
+ // Visualize the traceroute with animated hops
+ visualizeTraceroute(path, snrTowards, routeKey);
+}
+
+function visualizeTraceroute(path, snrTowards, routeKey) {
+ // Verify all nodes in path exist on map
+ const pathMarkers = [];
+ for (const nodeId of path) {
+ const marker = findNodeMarkerById(nodeId);
+ if (!marker) {
+ // Node not on map, skip this traceroute
+ activeTracerouteKeys.delete(routeKey);
+ return;
+ }
+ pathMarkers.push(marker);
+ }
+
+ // Store lines and overlays for this route key for cleanup
+ const routeElements = {
+ lines: [],
+ startOverlay: null,
+ endOverlay: null,
+ };
+ tracerouteLines[routeKey] = routeElements;
+
+ // Color starting node (first in path) green and destination node (last in path) red
+ const startMarker = pathMarkers[0];
+ const endMarker = pathMarkers[pathMarkers.length - 1];
+
+ const startOverlay = L.marker(startMarker.getLatLng(), {
+ icon: iconTracerouteStart,
+ zIndexOffset: 10000, // Ensure it's on top
+ }).addTo(traceroutesLayerGroup);
+
+ const endOverlay = L.marker(endMarker.getLatLng(), {
+ icon: iconTracerouteEnd,
+ zIndexOffset: 10000, // Ensure it's on top
+ }).addTo(traceroutesLayerGroup);
+
+ // Store overlays for cleanup
+ routeElements.startOverlay = startOverlay;
+ routeElements.endOverlay = endOverlay;
+
+ // Animate each hop sequentially
+ let hopIndex = 0;
+ const animateNextHop = () => {
+ if (hopIndex >= pathMarkers.length - 1) {
+ // All hops animated, cleanup after delay
+ setTimeout(() => {
+ if (tracerouteLines[routeKey]) {
+ const routeElements = tracerouteLines[routeKey];
+ // Remove all lines
+ if (routeElements.lines) {
+ routeElements.lines.forEach(line => {
+ line.remove();
+ });
+ }
+ // Remove node overlays
+ if (routeElements.startOverlay) {
+ routeElements.startOverlay.remove();
+ }
+ if (routeElements.endOverlay) {
+ routeElements.endOverlay.remove();
+ }
+ delete tracerouteLines[routeKey];
+ }
+ activeTracerouteKeys.delete(routeKey);
+ }, 2500);
+ return;
+ }
+
+ const fromMarker = pathMarkers[hopIndex];
+ const toMarker = pathMarkers[hopIndex + 1];
+ const snr = hopIndex < snrTowards.length ? snrTowards[hopIndex] : null;
+
+ // Use orange color for all traceroute lines
+ const lineColor = '#f97316'; // orange
+
+ // Create animated polyline for this hop with orange dotted style
+ const line = L.polyline([fromMarker.getLatLng(), toMarker.getLatLng()], {
+ color: lineColor,
+ weight: 4,
+ opacity: 0, // Start invisible
+ // dashArray: '10, 5', // Dotted line style
+ zIndexOffset: 10000,
+ }).addTo(traceroutesLayerGroup);
+
+ // Fade in animation
+ line.setStyle({ opacity: 1.0 });
+ tracerouteLines[routeKey].lines.push(line);
+
+ // Animate next hop after 700ms delay
+ hopIndex++;
+ setTimeout(animateNextHop, 700);
+ };
+
+ // Start animation
+ animateNextHop();
+}
+
+// Connect WebSocket when page loads
+connectWebSocket();
\ No newline at end of file
diff --git a/src/public/images/devices/HELTEC_MESH_POCKET.png b/src/public/images/devices/HELTEC_MESH_POCKET.png
new file mode 100644
index 0000000..ae7a797
Binary files /dev/null and b/src/public/images/devices/HELTEC_MESH_POCKET.png differ
diff --git a/src/public/images/devices/HELTEC_MESH_SOLAR.png b/src/public/images/devices/HELTEC_MESH_SOLAR.png
new file mode 100644
index 0000000..a6575ef
Binary files /dev/null and b/src/public/images/devices/HELTEC_MESH_SOLAR.png differ
diff --git a/src/public/images/devices/HELTEC_V4.png b/src/public/images/devices/HELTEC_V4.png
new file mode 100644
index 0000000..6dd4c49
Binary files /dev/null and b/src/public/images/devices/HELTEC_V4.png differ
diff --git a/src/public/images/devices/PORTDUINO.png b/src/public/images/devices/PORTDUINO.png
new file mode 100644
index 0000000..0ccea0d
Binary files /dev/null and b/src/public/images/devices/PORTDUINO.png differ
diff --git a/src/public/images/devices/SEEED_SOLAR_NODE.png b/src/public/images/devices/SEEED_SOLAR_NODE.png
new file mode 100644
index 0000000..7f894bc
Binary files /dev/null and b/src/public/images/devices/SEEED_SOLAR_NODE.png differ
diff --git a/src/public/images/devices/SEEED_WIO_TRACKER_L1.png b/src/public/images/devices/SEEED_WIO_TRACKER_L1.png
new file mode 100644
index 0000000..e432076
Binary files /dev/null and b/src/public/images/devices/SEEED_WIO_TRACKER_L1.png differ
diff --git a/src/public/images/devices/THINKNODE_M1.png b/src/public/images/devices/THINKNODE_M1.png
new file mode 100644
index 0000000..7995026
Binary files /dev/null and b/src/public/images/devices/THINKNODE_M1.png differ
diff --git a/src/public/images/devices/T_ETH_ELITE.png b/src/public/images/devices/T_ETH_ELITE.png
new file mode 100644
index 0000000..3a25fbc
Binary files /dev/null and b/src/public/images/devices/T_ETH_ELITE.png differ
diff --git a/src/public/images/devices/XIAO_NRF52_KIT.png b/src/public/images/devices/XIAO_NRF52_KIT.png
new file mode 100644
index 0000000..f3cc2eb
Binary files /dev/null and b/src/public/images/devices/XIAO_NRF52_KIT.png differ
diff --git a/src/public/index.html b/src/public/index.html
index e9443d4..ff76890 100644
--- a/src/public/index.html
+++ b/src/public/index.html
@@ -3,15 +3,15 @@
- STHLM-MESH MAP
-
+ DL4AX Meshtastic Map
+
-
-
+
+
@@ -48,118 +48,8 @@
-
+
+
@@ -168,32 +58,8 @@
-
-
-
-
-
-
Viktigt!
-
-
I Stockholm används LoRa preset Medium Range - Fast . För mer info klicka här .
-
-
-
-
-
-
-
-
-
+
@@ -207,13 +73,8 @@
-
-
-
-
-
-
-
STHLM-MESH
+
+
@@ -251,16 +112,6 @@
-
-
-
-
-
-
Nodes Disconnected Age
-
Nodes that have not uplinked to MQTT in this time will show as blue icons. Reload to update map.
-
- 15 minutes
- 30 minutes
- 45 minutes
- 1 hour
- 2 hours
- 3 hours
- 6 hours
- 12 hours
- 24 hours
- 2 days
- 3 days
- 4 days
- 5 days
- 6 days
- 7 days
-
-
-
Nodes Offline Age
@@ -1374,8 +1094,8 @@
-
Connections Time Period
-
Edges within this time period are shown in the Connections layer. Reload to update map.
+
Connections Max Age
+
Edges from traceroutes and neighbour info within this time period are shown in the Connections layer. Reload to update map.
5 minutes
15 minutes
@@ -1402,6 +1122,31 @@
Colors the connection lines by the average SNR in the worst direction. Reload to update map.
+
+
+
+
+
+
+
Bidirectional Connections Only
+
+
Only show connections where data flows in both directions. Reload to update map.
+
+
+
+
+
Connections Minimum SNR (dB)
+
Only show connections where at least one direction has SNR above this threshold. Leave empty to show all connections. Reload to update map.
+
+
+
+
+
+
Bidirectional Minimum SNR
+
+
If checked, all existing directions must meet the minimum SNR threshold (both directions if bidirectional, single direction if unidirectional).
+
+
Connections Max Distance (meters)
@@ -1573,2740 +1318,9 @@
-
-
-
-
-
-
-
-
-
+
+
+