diff --git a/src/index.js b/src/index.js
index 8d1eabe..58269f0 100644
--- a/src/index.js
+++ b/src/index.js
@@ -155,6 +155,14 @@ app.get('/api', async (req, res) => {
"time_to": "Only include traceroutes updated before this unix timestamp (milliseconds)"
}
},
+ {
+ "path": "/api/v1/connections",
+ "description": "Aggregated edges between nodes from traceroutes",
+ "params": {
+ "time_from": "Only include edges created after this unix timestamp (milliseconds)",
+ "time_to": "Only include edges created before this unix timestamp (milliseconds)"
+ }
+ },
{
"path": "/api/v1/nodes/:nodeId/position-history",
"description": "Position history for a meshtastic node",
@@ -698,6 +706,186 @@ app.get('/api/v1/traceroutes', async (req, res) => {
}
});
+// Aggregated edges endpoint
+// GET /api/v1/connections?time_from=...&time_to=...
+app.get('/api/v1/connections', async (req, res) => {
+ try {
+ const timeFrom = req.query.time_from ? parseInt(req.query.time_from) : undefined;
+ const timeTo = req.query.time_to ? parseInt(req.query.time_to) : undefined;
+
+ // Query edges from database
+ const edges = await prisma.edge.findMany({
+ where: {
+ created_at: {
+ gte: timeFrom ? new Date(timeFrom) : undefined,
+ lte: timeTo ? new Date(timeTo) : undefined,
+ },
+ // Only include edges where both nodes have positions
+ from_latitude: { not: null },
+ from_longitude: { not: null },
+ to_latitude: { not: null },
+ to_longitude: { not: null },
+ },
+ orderBy: [
+ { created_at: 'desc' },
+ { packet_id: 'desc' },
+ ],
+ });
+
+ // Collect all unique node IDs from edges
+ const nodeIds = new Set();
+ for (const edge of edges) {
+ nodeIds.add(edge.from_node_id);
+ nodeIds.add(edge.to_node_id);
+ }
+
+ // Fetch current positions for all nodes
+ const nodes = await prisma.node.findMany({
+ where: {
+ node_id: { in: Array.from(nodeIds) },
+ },
+ select: {
+ node_id: true,
+ latitude: true,
+ longitude: true,
+ },
+ });
+
+ // Create a map of current node positions
+ const nodePositions = new Map();
+ for (const node of nodes) {
+ nodePositions.set(node.node_id, {
+ latitude: node.latitude,
+ longitude: node.longitude,
+ });
+ }
+
+ // Filter edges: only include edges where both nodes are still at the same location
+ const validEdges = edges.filter(edge => {
+ const fromCurrentPos = nodePositions.get(edge.from_node_id);
+ const toCurrentPos = nodePositions.get(edge.to_node_id);
+
+ // Skip if either node doesn't exist or doesn't have a current position
+ if (!fromCurrentPos || !toCurrentPos ||
+ fromCurrentPos.latitude === null || fromCurrentPos.longitude === null ||
+ toCurrentPos.latitude === null || toCurrentPos.longitude === null) {
+ return false;
+ }
+
+ // Check if stored positions match current positions
+ const fromMatches = fromCurrentPos.latitude === edge.from_latitude &&
+ fromCurrentPos.longitude === edge.from_longitude;
+ const toMatches = toCurrentPos.latitude === edge.to_latitude &&
+ toCurrentPos.longitude === edge.to_longitude;
+
+ return fromMatches && toMatches;
+ });
+
+ // Normalize node pairs: always use min/max to treat A->B and B->A as same connection
+ const connectionsMap = new Map();
+
+ for (const edge of validEdges) {
+ const nodeA = edge.from_node_id < edge.to_node_id ? edge.from_node_id : edge.to_node_id;
+ const nodeB = edge.from_node_id < edge.to_node_id ? edge.to_node_id : edge.from_node_id;
+ const key = `${nodeA}-${nodeB}`;
+
+ if (!connectionsMap.has(key)) {
+ connectionsMap.set(key, {
+ node_a: nodeA,
+ node_b: nodeB,
+ direction_ab: [], // A -> B edges
+ direction_ba: [], // B -> A edges
+ });
+ }
+
+ const connection = connectionsMap.get(key);
+ const isAB = edge.from_node_id === nodeA;
+
+ // Add edge to appropriate direction
+ if (isAB) {
+ connection.direction_ab.push({
+ snr: edge.snr,
+ snr_db: edge.snr / 4, // Convert to dB
+ created_at: edge.created_at,
+ packet_id: edge.packet_id,
+ source: edge.source,
+ });
+ } else {
+ connection.direction_ba.push({
+ snr: edge.snr,
+ snr_db: edge.snr / 4,
+ created_at: edge.created_at,
+ packet_id: edge.packet_id,
+ source: edge.source,
+ });
+ }
+ }
+
+ // Aggregate each connection
+ const connections = Array.from(connectionsMap.values()).map(conn => {
+ // Deduplicate edges by packet_id for each direction (keep first occurrence, which is most recent)
+ const dedupeByPacketId = (edges) => {
+ const seen = new Set();
+ return edges.filter(edge => {
+ if (seen.has(edge.packet_id)) {
+ return false;
+ }
+ seen.add(edge.packet_id);
+ return true;
+ });
+ };
+
+ const deduplicatedAB = dedupeByPacketId(conn.direction_ab);
+ const deduplicatedBA = dedupeByPacketId(conn.direction_ba);
+
+ // Calculate average SNR for A->B (using deduplicated edges)
+ const avgSnrAB = deduplicatedAB.length > 0
+ ? deduplicatedAB.reduce((sum, e) => sum + e.snr_db, 0) / deduplicatedAB.length
+ : null;
+
+ // Calculate average SNR for B->A (using deduplicated edges)
+ const avgSnrBA = deduplicatedBA.length > 0
+ ? deduplicatedBA.reduce((sum, e) => sum + e.snr_db, 0) / deduplicatedBA.length
+ : null;
+
+ // Get last 5 edges for each direction (already sorted by created_at DESC, packet_id DESC, now deduplicated)
+ const last5AB = deduplicatedAB.slice(0, 5);
+ const last5BA = deduplicatedBA.slice(0, 5);
+
+ // Determine worst average SNR
+ const worstAvgSnrDb = [avgSnrAB, avgSnrBA]
+ .filter(v => v !== null)
+ .reduce((min, val) => val < min ? val : min, Infinity);
+
+ return {
+ node_a: conn.node_a,
+ node_b: conn.node_b,
+ direction_ab: {
+ avg_snr_db: avgSnrAB,
+ last_5_edges: last5AB,
+ total_count: deduplicatedAB.length, // Use deduplicated count
+ },
+ direction_ba: {
+ avg_snr_db: avgSnrBA,
+ last_5_edges: last5BA,
+ total_count: deduplicatedBA.length, // Use deduplicated count
+ },
+ worst_avg_snr_db: worstAvgSnrDb !== Infinity ? worstAvgSnrDb : null,
+ };
+ }).filter(conn => conn.worst_avg_snr_db !== null); // Only return connections with at least one direction
+
+ res.json({
+ connections: connections,
+ });
+
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({
+ message: "Something went wrong, try again later.",
+ });
+ }
+});
+
app.get('/api/v1/nodes/:nodeId/position-history', async (req, res) => {
try {
diff --git a/src/public/index.html b/src/public/index.html
index 6a71a50..dd0cab6 100644
--- a/src/public/index.html
+++ b/src/public/index.html
@@ -1440,6 +1440,36 @@
Map will animate flying to nodes.
+
+
+
+
Edges within this time period are shown in the Connections layer. Reload to update map.
+
+
+
+
+
+
+
+
+
+
+
+
Colors the connection lines by the average SNR in the worst direction. Reload to update map.
+
+
@@ -1725,6 +1755,29 @@
return localStorage.setItem("config_zoom_level_go_to_node", value);
}
+ function getConfigConnectionsTimePeriodInSeconds() {
+ const value = localStorage.getItem("config_connections_time_period_in_seconds");
+ // default to 24 hours if unset
+ return value != null ? parseInt(value) : 86400;
+ }
+
+ 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 isMobile() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}
@@ -1748,6 +1801,8 @@
configAutoUpdatePositionInUrl: window.getConfigAutoUpdatePositionInUrl(),
configEnableMapAnimations: window.getConfigEnableMapAnimations(),
configTemperatureFormat: window.getConfigTemperatureFormat(),
+ configConnectionsTimePeriodInSeconds: window.getConfigConnectionsTimePeriodInSeconds(),
+ configConnectionsColoredLines: window.getConfigConnectionsColoredLines(),
isShowingHardwareModels: false,
hardwareModelStats: null,
@@ -2673,6 +2728,12 @@
configTemperatureFormat() {
window.setConfigTemperatureFormat(this.configTemperatureFormat);
},
+ configConnectionsTimePeriodInSeconds() {
+ window.setConfigConnectionsTimePeriodInSeconds(this.configConnectionsTimePeriodInSeconds);
+ },
+ configConnectionsColoredLines() {
+ window.setConfigConnectionsColoredLines(this.configConnectionsColoredLines);
+ },
deviceMetricsTimeRange() {
this.loadNodeDeviceMetrics(this.selectedNode.node_id);
},
@@ -2771,6 +2832,7 @@
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({
@@ -2859,6 +2921,7 @@
"Legend": legendLayerGroup,
"Neighbours": neighboursLayerGroup,
"Backbone Connection": backboneNeighboursLayerGroup,
+ "Connections": connectionsLayerGroup,
"Waypoints": waypointsLayerGroup,
"Position History": nodePositionHistoryLayerGroup,
"Traceroutes": traceroutesLayerGroup,
@@ -2882,6 +2945,9 @@
if(enabledOverlayLayers.includes("Backbone Connection")){
backboneNeighboursLayerGroup.addTo(map);
}
+ if(enabledOverlayLayers.includes("Connections")){
+ connectionsLayerGroup.addTo(map);
+ }
if(enabledOverlayLayers.includes("Waypoints")){
waypointsLayerGroup.addTo(map);
}
@@ -3053,6 +3119,10 @@
traceroutesLayerGroup.clearLayers();
}
+ function clearAllConnections() {
+ connectionsLayerGroup.clearLayers();
+ }
+
function closeAllPopups() {
map.eachLayer(function(layer) {
if(layer.options.pane === "popupPane"){
@@ -3120,9 +3190,40 @@
}
function getColourForSnr(snr) {
- if(snr >= -5) return "#16a34a"; // good
- if(snr > -15) return "#fff200"; // meh
- if(snr <= -15) return "#dc2626"; // bad
+ 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 cleanUpNodeNeighbours() {
@@ -3492,6 +3593,7 @@
clearAllNeighbours();
clearAllWaypoints();
clearAllTraceroutes();
+ clearAllConnections();
clearNodeOutline();
cleanUpNodeNeighbours();
}
@@ -3968,6 +4070,109 @@
}
}
+ 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;
+ }
+
+ // Calculate distance between nodes
+ const distanceInMeters = nodeAMarker.getLatLng().distanceTo(nodeBMarker.getLatLng()).toFixed(2);
+ 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
+ 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();
+ console.log(edge.source);
+ 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 += `
`;
+
+ // 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();
+ });
+ }
+ }
+
function onPositionHistoryUpdated(updatedPositionHistories) {
let positionHistoryLinesCords = [];
@@ -4111,6 +4316,17 @@
onTracerouteEdgesUpdated([]);
});
+ // 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) {