diff --git a/meshview/templates/map.html b/meshview/templates/map.html
index 6b2c817..08e16fb 100644
--- a/meshview/templates/map.html
+++ b/meshview/templates/map.html
@@ -30,13 +30,13 @@
{% block body %}
@@ -105,16 +105,16 @@ function applyTranslationsMap(root = document) {
}
/* ======================================================
- EXISTING MAP LOGIC (UNCHANGED)
+ EXISTING MAP LOGIC
====================================================== */
-// ---------------------- Map Initialization ----------------------
var map = L.map('map');
-L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom:19, attribution:'© OpenStreetMap' }).addTo(map);
+L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',
+ { maxZoom:19, attribution:'© OpenStreetMap' }).addTo(map);
-// ---------------------- Globals ----------------------
-var nodes=[], markers={}, markerById={}, nodeMap = new Map();
-var edgesData=[], edgeLayer = L.layerGroup().addTo(map), selectedNodeId = null;
+// Data structures
+var nodes = [], markers = {}, markerById = {}, nodeMap = new Map();
+var edgeLayer = L.layerGroup().addTo(map), selectedNodeId = null;
var activeBlinks = new Map(), lastImportTime = null;
var mapInterval = 0;
@@ -137,12 +137,9 @@ const channelSet = new Set();
map.on("popupopen", function (e) {
const popupEl = e.popup.getElement();
- if (popupEl) {
- applyTranslationsMap(popupEl);
- }
+ if (popupEl) applyTranslationsMap(popupEl);
});
-
function timeAgo(date){
const diff = Date.now() - new Date(date);
const s = Math.floor(diff/1000), m = Math.floor(s/60),
@@ -158,10 +155,14 @@ function hashToColor(str){
}
function isInvalidCoord(n){
- return !n||!n.lat||!n.long||n.lat===0||n.long===0||Number.isNaN(n.lat)||Number.isNaN(n.long);
+ return !n || !n.lat || !n.long || n.lat === 0 || n.long === 0 ||
+ Number.isNaN(n.lat) || Number.isNaN(n.long);
}
-// ---------------------- Packet Fetching ----------------------
+/* ======================================================
+ PACKET FETCHING (unchanged)
+ ====================================================== */
+
function fetchLatestPacket(){
fetch(`/api/packets?limit=1`)
.then(r=>r.json())
@@ -184,18 +185,20 @@ function fetchNewPackets(){
.then(data=>{
if(!data.packets || data.packets.length===0) return;
let latest = lastImportTime;
+
data.packets.forEach(pkt=>{
if(pkt.import_time_us > latest) latest = pkt.import_time_us;
+
const marker = markerById[pkt.from_node_id];
const nodeData = nodeMap.get(pkt.from_node_id);
if(marker && nodeData) blinkNode(marker,nodeData.long_name,pkt.portnum);
});
+
lastImportTime = latest;
})
.catch(console.error);
}
-// ---------------------- Polling ----------------------
let packetInterval=null;
function startPacketFetcher(){
@@ -217,12 +220,10 @@ document.addEventListener("visibilitychange",()=>{
document.hidden?stopPacketFetcher():startPacketFetcher();
});
-// ---------------------- WAIT FOR CONFIG ----------------------
async function waitForConfig() {
while (typeof window._siteConfigPromise === "undefined") {
await new Promise(r => setTimeout(r, 100));
}
-
try {
const cfg = await window._siteConfigPromise;
return cfg.site || {};
@@ -232,13 +233,11 @@ async function waitForConfig() {
}
}
-// ---------------------- Load Config & Start Polling ----------------------
async function initMapPolling() {
try {
const site = await waitForConfig();
mapInterval = parseInt(site.map_interval, 10) || 0;
- // --- URL params ---
const params = new URLSearchParams(window.location.search);
const lat = parseFloat(params.get('lat'));
const lng = parseFloat(params.get('lng'));
@@ -252,6 +251,7 @@ async function initMapPolling() {
else {
const tl = [parseFloat(site.map_top_left_lat), parseFloat(site.map_top_left_lon)];
const br = [parseFloat(site.map_bottom_right_lat), parseFloat(site.map_bottom_right_lon)];
+
if (tl.every(isFinite) && br.every(isFinite)) {
map.fitBounds([tl, br]);
window.configBoundsApplied = true;
@@ -268,45 +268,45 @@ async function initMapPolling() {
initMapPolling();
-// ---------------------- Load Nodes + Edges ----------------------
+/* ======================================================
+ LOAD NODES
+ ====================================================== */
+
fetch('/api/nodes?days_active=3')
.then(r=>r.json())
.then(data=>{
if(!data.nodes) return;
nodes = data.nodes.map(n=>({
- key: n.node_id??n.id,
+ key: n.node_id ?? n.id,
id: n.id,
node_id: n.node_id,
- lat: n.last_lat? n.last_lat/1e7 : null,
- long: n.last_long? n.last_long/1e7 : null,
- long_name: n.long_name||"",
- short_name: n.short_name||"",
- channel: n.channel||"",
- hw_model: n.hw_model||"",
- role: n.role||"",
- firmware: n.firmware||"",
- last_update: n.last_update||"",
+ lat: n.last_lat ? n.last_lat/1e7 : null,
+ long: n.last_long ? n.last_long/1e7 : null,
+ long_name: n.long_name || "",
+ short_name: n.short_name || "",
+ channel: n.channel || "",
+ hw_model: n.hw_model || "",
+ role: n.role || "",
+ firmware: n.firmware || "",
+ last_update: n.last_update || "",
isRouter: (n.role||"").toLowerCase().includes("router")
}));
nodes.forEach(n=>{
- nodeMap.set(n.key,n);
+ nodeMap.set(n.key, n);
if(n.channel) channelSet.add(n.channel);
});
renderNodesOnMap();
createChannelFilters();
-
- return fetch('/api/edges');
- })
- .then(r=>r?r.json():null)
- .then(data=>{
- if(data && data.edges) edgesData=data.edges;
})
.catch(console.error);
-// ---------------------- Render Nodes ----------------------
+/* ======================================================
+ RENDER NODES
+ ====================================================== */
+
function renderNodesOnMap(){
nodes.forEach(node=>{
if(isInvalidCoord(node)) return;
@@ -349,74 +349,87 @@ function renderNodesOnMap(){
onNodeClick(node);
marker.bindPopup(popup).openPopup();
});
-
-
});
- // Still apply translations for popup content
setTimeout(() => applyTranslationsMap(), 50);
}
-// ---------------------- Render Edges ----------------------
-function onNodeClick(node){
+/* ======================================================
+ ⭐ NEW: DYNAMIC EDGE LOADING
+ ====================================================== */
+
+async function onNodeClick(node){
selectedNodeId = node.key;
edgeLayer.clearLayers();
- edgesData.forEach(edge=>{
- if(edge.from !== node.key && edge.to !== node.key) return;
+ try {
+ const res = await fetch(`/api/edges?node_id=${node.key}`);
+ const data = await res.json();
+ const edges = data.edges || [];
- const f=nodeMap.get(edge.from);
- const t=nodeMap.get(edge.to);
+ edges.forEach(edge=>{
+ const f = nodeMap.get(edge.from);
+ const t = nodeMap.get(edge.to);
- if(!f || !t || isInvalidCoord(f) || isInvalidCoord(t)) return;
+ if(!f || !t || isInvalidCoord(f) || isInvalidCoord(t)) return;
- const color = edge.type==="neighbor" ? "gray" : "orange";
- const line = L.polyline([[f.lat,f.long],[t.lat,t.long]],{color,weight:3}).addTo(edgeLayer);
-
- if(edge.type==="traceroute"){
- L.polylineDecorator(line,{
- patterns:[
- {
- offset:'100%',
- repeat:0,
- symbol:L.Symbol.arrowHead({
- pixelSize:5,polygon:false,
- pathOptions:{stroke:true,color}
- })
- }
- ]
+ const color = edge.type === "neighbor" ? "gray" : "orange";
+ const line = L.polyline([[f.lat, f.long], [t.lat, t.long]], {
+ color, weight: 3
}).addTo(edgeLayer);
- }
- });
+
+ if(edge.type === "traceroute"){
+ L.polylineDecorator(line, {
+ patterns: [
+ {
+ offset: '100%',
+ repeat: 0,
+ symbol: L.Symbol.arrowHead({
+ pixelSize:5,
+ polygon:false,
+ pathOptions:{stroke:true,color}
+ })
+ }
+ ]
+ }).addTo(edgeLayer);
+ }
+ });
+
+ } catch(err){
+ console.error("Failed to load edges for node", node.key, err);
+ }
}
-map.on('click',e=>{
+map.on('click', e=>{
if(!e.originalEvent.target.classList.contains('leaflet-interactive')){
edgeLayer.clearLayers();
selectedNodeId=null;
}
});
-// ---------------------- Blinking ----------------------
+/* ======================================================
+ BLINKING
+ ====================================================== */
+
function blinkNode(marker,longName,portnum){
if(!map.hasLayer(marker)) return;
+
if(activeBlinks.has(marker)){
clearInterval(activeBlinks.get(marker));
- marker.setStyle({fillColor:marker.originalColor});
+ marker.setStyle({ fillColor: marker.originalColor });
if(marker.tooltip) map.removeLayer(marker.tooltip);
}
- let blinkCount=0;
- const portName = portMap[portnum] || `Port ${portnum}`;
-
+ let blinkCount = 0;
const tooltip = L.tooltip({
permanent:true,
direction:'top',
offset:[0,-marker.options.radius-5],
className:'blinking-tooltip'
- }).setContent(`${longName} (${portName})`)
- .setLatLng(marker.getLatLng())
- .addTo(map);
+ })
+ .setContent(`${longName} (${portMap[portnum] || "Port "+portnum})`)
+ .setLatLng(marker.getLatLng())
+ .addTo(map);
marker.tooltip = tooltip;
@@ -428,27 +441,32 @@ function blinkNode(marker,longName,portnum){
marker.bringToFront();
}
blinkCount++;
+
if(blinkCount>7){
clearInterval(interval);
- marker.setStyle({fillColor:marker.originalColor});
+ marker.setStyle({ fillColor: marker.originalColor });
map.removeLayer(tooltip);
activeBlinks.delete(marker);
}
+
},500);
- activeBlinks.set(marker,interval);
+ activeBlinks.set(marker, interval);
}
-// ---------------------- Channel Filters ----------------------
+/* ======================================================
+ CHANNEL FILTERS
+ ====================================================== */
+
function createChannelFilters(){
const filterContainer = document.getElementById("filter-container");
const saved = JSON.parse(localStorage.getItem("mapFilters") || "{}");
channelSet.forEach(channel=>{
- const cb = document.createElement("input");
- cb.type = "checkbox";
- cb.className = "filter-checkbox";
- cb.id = `filter-channel-${channel}`;
+ const cb=document.createElement("input");
+ cb.type="checkbox";
+ cb.className="filter-checkbox";
+ cb.id=`filter-channel-${channel}`;
cb.checked = saved[channel] !== false;
cb.addEventListener("change", saveFiltersToLocalStorage);
@@ -456,14 +474,14 @@ function createChannelFilters(){
filterContainer.appendChild(cb);
- const label = document.createElement("label");
- label.htmlFor = cb.id;
- label.innerText = channel;
+ const label=document.createElement("label");
+ label.htmlFor=cb.id;
+ label.innerText=channel;
label.style.color = hashToColor(channel);
filterContainer.appendChild(label);
});
- const routerOnly = document.getElementById("filter-routers-only");
+ const routerOnly=document.getElementById("filter-routers-only");
routerOnly.checked = saved["routersOnly"] || false;
routerOnly.addEventListener("change", saveFiltersToLocalStorage);
@@ -491,16 +509,19 @@ function updateNodeVisibility(){
nodes.forEach(n=>{
const marker = markerById[n.key];
if(marker){
- const visible = (!routerOnly || n.isRouter) &&
- activeChannels.includes(n.channel);
+ const visible =
+ (!routerOnly || n.isRouter) &&
+ activeChannels.includes(n.channel);
- if(visible) map.addLayer(marker);
- else map.removeLayer(marker);
+ visible ? map.addLayer(marker) : map.removeLayer(marker);
}
});
}
-// ---------------------- Share / Reset ----------------------
+/* ======================================================
+ SHARE / RESET
+ ====================================================== */
+
function shareCurrentView() {
const c = map.getCenter();
const url = `${window.location.origin}/map?lat=${c.lat.toFixed(6)}&lng=${c.lng.toFixed(6)}&zoom=${map.getZoom()}`;
@@ -510,6 +531,7 @@ function shareCurrentView() {
const old = btn.textContent;
btn.textContent = '✓ ' + (mapTranslations.link_copied || 'Link Copied!');
btn.style.backgroundColor = '#2196F3';
+
setTimeout(()=>{
btn.textContent = old;
btn.style.backgroundColor = '#4CAF50';
@@ -527,7 +549,7 @@ function resetFiltersToDefaults(){
}
/* ======================================================
- START TRANSLATION + PAGE LOAD
+ TRANSLATION LOAD
====================================================== */
document.addEventListener("DOMContentLoaded", () => {
diff --git a/meshview/web_api/api.py b/meshview/web_api/api.py
index 667df38..923c518 100644
--- a/meshview/web_api/api.py
+++ b/meshview/web_api/api.py
@@ -421,6 +421,19 @@ async def api_stats_count(request):
async def api_edges(request):
since = datetime.datetime.now() - datetime.timedelta(hours=48)
filter_type = request.query.get("type")
+
+ # NEW → optional single-node filter
+ node_filter_str = request.query.get("node_id")
+ node_filter = None
+ if node_filter_str:
+ try:
+ node_filter = int(node_filter_str)
+ except ValueError:
+ return web.json_response(
+ {"error": "node_id must be integer"},
+ status=400
+ )
+
edges = {}
traceroute_count = 0
neighbor_packet_count = 0
@@ -429,17 +442,14 @@ async def api_edges(request):
# --- Traceroute edges ---
if filter_type in (None, "traceroute"):
-
async for tr in store.get_traceroutes(since):
traceroute_count += 1
try:
route = decode_payload.decode_payload(PortNum.TRACEROUTE_APP, tr.route)
- except Exception as e:
- print(f" ERROR decoding traceroute {tr.id}: {e}")
+ except Exception:
continue
- # Build full path
path = [tr.packet.from_node_id] + list(route.route)
path.append(tr.packet.to_node_id if tr.done else tr.gateway_node_id)
@@ -454,16 +464,13 @@ async def api_edges(request):
neighbor_packet_count = len(packets)
for packet in packets:
- packet_id = getattr(packet, "id", "?")
try:
_, neighbor_info = decode_payload.decode(packet)
- except Exception as e:
- print(f" ERROR decoding NeighborInfo packet {packet_id}: {e}")
+ except Exception:
continue
for node in neighbor_info.neighbors:
edge = (node.node_id, packet.from_node_id)
-
if edge not in edges:
edges[edge] = "neighbor"
edges_added_neighbor += 1
@@ -474,6 +481,13 @@ async def api_edges(request):
for (frm, to), edge_type in edges.items()
]
+ # NEW → apply node_id filtering
+ if node_filter is not None:
+ edges_list = [
+ e for e in edges_list
+ if e["from"] == node_filter or e["to"] == node_filter
+ ]
+
return web.json_response({"edges": edges_list})