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
`
- + ``
- //+ ``
- + ``
- + ``
- + ` 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
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 @@
+
+
+
+
+
+
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
@@ -1094,8 +1374,8 @@
-
Connections Max Age
-
Edges from traceroutes and neighbour info within this time period are shown in the Connections layer. Reload to update map.
+
Connections Time Period
+
Edges within this time period are shown in the Connections layer. Reload to update map.
5 minutes
15 minutes
@@ -1122,31 +1402,6 @@
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)
@@ -1318,9 +1573,2740 @@
-
-
-
+
+
+
+
+
+
+
+
+