From 76d63ffe75a1b29d9a82ca680375707444eb4977 Mon Sep 17 00:00:00 2001 From: you Date: Fri, 20 Mar 2026 14:56:08 +0000 Subject: [PATCH] feat: observers as map markers (purple stars) with computed locations - Removed dead 'MQTT Connected Only' checkbox (never worked) - Added 'observer' role type with purple star marker - Observer locations computed from average of nodes they've seen - Observer popup with name, IATA, packets, link to detail page - Role filter checkbox includes observers with count --- public/index.html | 2 +- public/map.js | 73 ++++++++++++++++++++++++++++++++++++++++------- server.js | 22 +++++++++++++- 3 files changed, 85 insertions(+), 12 deletions(-) diff --git a/public/index.html b/public/index.html index 2909f6d0..dc6591a0 100644 --- a/public/index.html +++ b/public/index.html @@ -82,7 +82,7 @@ - + diff --git a/public/map.js b/public/map.js index 3e0fc585..386783e2 100644 --- a/public/map.js +++ b/public/map.js @@ -8,7 +8,7 @@ let clusterGroup = null; let nodes = []; let observers = []; - let filters = { repeater: true, companion: true, room: true, sensor: true, lastHeard: '30d', mqttOnly: false, neighbors: false, clusters: false }; + let filters = { repeater: true, companion: true, room: true, sensor: true, observer: true, lastHeard: '30d', neighbors: false, clusters: false }; let wsHandler = null; let heatLayer = null; let userHasMoved = false; @@ -23,10 +23,11 @@ companion: { color: '#2563eb', shape: 'circle', radius: 8, weight: 2 }, // blue circle room: { color: '#16a34a', shape: 'square', radius: 9, weight: 2 }, // green square sensor: { color: '#d97706', shape: 'triangle', radius: 8, weight: 2 }, // amber triangle + observer: { color: '#8b5cf6', shape: 'star', radius: 11, weight: 2 }, // purple star }; - const ROLE_LABELS = { repeater: 'Repeaters', companion: 'Companions', room: 'Room Servers', sensor: 'Sensors' }; - const ROLE_COLORS = { repeater: '#dc2626', companion: '#2563eb', room: '#16a34a', sensor: '#d97706' }; + const ROLE_LABELS = { repeater: 'Repeaters', companion: 'Companions', room: 'Room Servers', sensor: 'Sensors', observer: 'Observers' }; + const ROLE_COLORS = { repeater: '#dc2626', companion: '#2563eb', room: '#16a34a', sensor: '#d97706', observer: '#8b5cf6' }; function makeMarkerIcon(role) { const s = ROLE_STYLE[role] || ROLE_STYLE.companion; @@ -43,6 +44,19 @@ case 'triangle': path = ``; break; + case 'star': { + // 5-pointed star + const cx = c, cy = c, outer = c - 1, inner = outer * 0.4; + let pts = ''; + for (let i = 0; i < 5; i++) { + const aOuter = (i * 72 - 90) * Math.PI / 180; + const aInner = ((i * 72) + 36 - 90) * Math.PI / 180; + pts += `${cx + outer * Math.cos(aOuter)},${cy + outer * Math.sin(aOuter)} `; + pts += `${cx + inner * Math.cos(aInner)},${cy + inner * Math.sin(aInner)} `; + } + path = ``; + break; + } default: // circle path = ``; } @@ -74,7 +88,6 @@
Filters -
@@ -152,7 +165,6 @@ // Bind controls document.getElementById('mcClusters').addEventListener('change', e => { filters.clusters = e.target.checked; renderMarkers(); }); document.getElementById('mcHeatmap').addEventListener('change', e => { toggleHeatmap(e.target.checked); }); - document.getElementById('mcMqtt').addEventListener('change', e => { filters.mqttOnly = e.target.checked; renderMarkers(); }); document.getElementById('mcNeighbors').addEventListener('change', e => { filters.neighbors = e.target.checked; renderMarkers(); }); document.getElementById('mcLastHeard').addEventListener('change', e => { filters.lastHeard = e.target.value; loadNodes(); }); @@ -258,11 +270,12 @@ try { const data = await api(`/nodes?limit=10000&lastHeard=${filters.lastHeard}`, { ttl: CLIENT_TTL.nodeList }); nodes = data.nodes || []; - buildRoleChecks(data.counts || {}); - // Load observers for jump buttons + // Load observers for jump buttons + map markers const obsData = await api('/observers', { ttl: CLIENT_TTL.observers }); observers = obsData.observers || []; + + buildRoleChecks(data.counts || {}); buildJumpButtons(); renderMarkers(); @@ -277,12 +290,14 @@ const el = document.getElementById('mcRoleChecks'); if (!el) return; el.innerHTML = ''; - for (const role of ['repeater', 'companion', 'room', 'sensor']) { - const count = counts[role + 's'] || 0; + const obsCount = observers.filter(o => o.lat && o.lon).length; + const roles = ['repeater', 'companion', 'room', 'sensor', 'observer']; + const shapeMap = { repeater: '◆', companion: '●', room: '■', sensor: '▲', observer: '★' }; + for (const role of roles) { + const count = role === 'observer' ? obsCount : (counts[role + 's'] || 0); const cbId = 'mcRole_' + role; const lbl = document.createElement('label'); lbl.setAttribute('for', cbId); - const shapeMap = { repeater: '◆', companion: '●', room: '■', sensor: '▲' }; const shape = shapeMap[role] || '●'; lbl.innerHTML = ` ${ROLE_LABELS[role]} (${count})`; lbl.querySelector('input').addEventListener('change', e => { @@ -358,6 +373,44 @@ marker.bindPopup(buildPopup(node), { maxWidth: 280 }); markerLayer.addLayer(marker); } + + // Add observer markers + if (filters.observer) { + for (const obs of observers) { + if (!obs.lat || !obs.lon) continue; + const icon = makeMarkerIcon('observer'); + const marker = L.marker([obs.lat, obs.lon], { + icon, + alt: `${obs.name || obs.id} (observer)`, + }); + marker.bindPopup(buildObserverPopup(obs), { maxWidth: 280 }); + markerLayer.addLayer(marker); + } + } + } + + function buildObserverPopup(obs) { + const name = safeEsc(obs.name || obs.id || 'Unknown'); + const iata = obs.iata ? `${safeEsc(obs.iata)}` : ''; + const lastSeen = obs.last_seen ? timeAgo(obs.last_seen) : '—'; + const packets = (obs.packet_count || 0).toLocaleString(); + const loc = `${obs.lat.toFixed(5)}, ${obs.lon.toFixed(5)}`; + const roleBadge = `OBSERVER`; + + return ` +
+

${name}

+ ${roleBadge} ${iata} +
+
Location
+
${loc}
+
Last Seen
+
${lastSeen}
+
Packets
+
${packets}
+
+ View Detail → +
`; } function buildPopup(node) { diff --git a/server.js b/server.js index af46701d..c08f7cc6 100644 --- a/server.js +++ b/server.js @@ -1663,10 +1663,30 @@ app.get('/api/observers', (req, res) => { const _c = cache.get('observers'); if (_c) return res.json(_c); const observers = db.getObservers(); const oneHourAgo = new Date(Date.now() - 3600000).toISOString(); + // Pre-compute observer locations from nodes they've seen (via packets) + const nodeLocStmt = db.db.prepare("SELECT lat, lon FROM nodes WHERE public_key = ? AND lat IS NOT NULL AND lon IS NOT NULL"); const result = observers.map(o => { const obsPackets = pktStore.byObserver.get(o.id) || []; const lastHour = { count: obsPackets.filter(p => p.timestamp > oneHourAgo).length }; - return { ...o, packetsLastHour: lastHour.count }; + let lat = null, lon = null; + // Collect unique node pubkeys from this observer's packets + const seen = new Set(); + for (const p of obsPackets) { + try { + const d = JSON.parse(p.decoded_json || '{}'); + if (d.pubKey) seen.add(d.pubKey); + } catch {} + } + const locs = []; + for (const pk of seen) { + const n = nodeLocStmt.get(pk); + if (n) locs.push(n); + } + if (locs.length > 0) { + lat = locs.reduce((s, n) => s + n.lat, 0) / locs.length; + lon = locs.reduce((s, n) => s + n.lon, 0) / locs.length; + } + return { ...o, packetsLastHour: lastHour.count, lat, lon }; }); const _oResult = { observers: result, server_time: new Date().toISOString() }; cache.set('observers', _oResult, TTL.observers);