diff --git a/docs/COVERAGE.md b/docs/COVERAGE.md
index 6aa6858..f478dbf 100644
--- a/docs/COVERAGE.md
+++ b/docs/COVERAGE.md
@@ -32,27 +32,5 @@ and the last point above the threshold forms the outline.
- No terrain or building data is used (area mode only).
- Results are sensitive to power, height, and threshold.
- Environmental factors can cause large real-world deviations.
- - Observed coverage depends on gateway locations and recent traffic volume.
-
-
-
-## Observed coverage (real data)
-
-Meshview can also draw an **observed coverage** perimeter based on real packet
-sightings. This uses packets **from the node** and the gateways that heard them.
-We filter to **direct/1-hop** sightings (`hop_start - hop_limit <= 1`) and then:
-
-1. Compute distance + bearing from the sender to each gateway with location.
-2. Bucket by bearing (default 5°).
-3. Keep the **farthest** gateway in each bearing bucket.
-4. Connect those points into a perimeter polygon.
-
-This gives a **real-world envelope** that reflects terrain, antenna placement,
-and environment. It improves over time as more packets are observed.
-
-Tuning knobs:
-- `max_hops` (default 1)
-- `bearing_step` (default 10°)
-- `packets_limit` (default 50 most recent packets)
diff --git a/meshview/lang/en.json b/meshview/lang/en.json
index a471e11..a4ed9d2 100644
--- a/meshview/lang/en.json
+++ b/meshview/lang/en.json
@@ -109,7 +109,9 @@
"firmware": "Firmware:",
"link_copied": "Link Copied!",
"legend_traceroute": "Traceroute (with arrows)",
- "legend_neighbor": "Neighbor"
+ "legend_neighbor": "Neighbor",
+ "nearby_nodes_title": "Multiple nodes here",
+ "nearby_nodes_hint": "Select a node:"
},
@@ -217,12 +219,6 @@
"copy_import_url": "Copy Import URL",
"show_qr_code": "Show QR Code",
"toggle_coverage": "Predicted Coverage",
- "toggle_observed_coverage": "Observed Coverage",
- "observed_settings": "Observed Settings",
- "max_hops": "Max Hops",
- "bearing_step": "Bearing Step",
- "packets_limit": "Packets",
- "refresh": "Refresh",
"location_required": "Location required for coverage",
"coverage_help": "Coverage Help",
"share_contact_qr": "Share Contact QR",
diff --git a/meshview/lang/es.json b/meshview/lang/es.json
index ee7bb52..f444363 100644
--- a/meshview/lang/es.json
+++ b/meshview/lang/es.json
@@ -107,7 +107,9 @@
"firmware": "Firmware:",
"link_copied": "¡Enlace copiado!",
"legend_traceroute": "Ruta de traceroute (flechas de dirección)",
- "legend_neighbor": "Vínculo de vecinos"
+ "legend_neighbor": "Vínculo de vecinos",
+ "nearby_nodes_title": "Varios nodos aquí",
+ "nearby_nodes_hint": "Selecciona un nodo:"
},
"stats": {
@@ -203,12 +205,6 @@
"copy_import_url": "Copiar URL de importación",
"show_qr_code": "Mostrar código QR",
"toggle_coverage": "Cobertura predicha",
- "toggle_observed_coverage": "Cobertura observada",
- "observed_settings": "Ajustes observados",
- "max_hops": "Máx. saltos",
- "bearing_step": "Paso de rumbo",
- "packets_limit": "Paquetes",
- "refresh": "Actualizar",
"location_required": "Se requiere ubicación para la cobertura",
"coverage_help": "Ayuda de cobertura",
"share_contact_qr": "Compartir contacto QR",
diff --git a/meshview/templates/map.html b/meshview/templates/map.html
index 78aafbe..9e1c42e 100644
--- a/meshview/templates/map.html
+++ b/meshview/templates/map.html
@@ -71,6 +71,10 @@
#unmapped-list li:last-child { border-bottom: none; }
.unmapped-node { font-weight: 400; color: #000; }
.unmapped-empty { color: #666; font-style: italic; }
+ .nearby-popup { font-size: 13px; }
+ .nearby-popup .nearby-list { margin: 6px 0 0; padding-left: 16px; }
+ .nearby-popup .nearby-list li { margin: 3px 0; }
+ .nearby-popup .nearby-distance { color: #666; font-size: 12px; }
{% endblock %}
@@ -201,6 +205,58 @@ function timeAgoFromUs(us){
return d>0?d+"d":h>0?h+"h":m>0?m+"m":s+"s";
}
+const NEARBY_METERS = 20;
+
+function escapeHtml(text){
+ return String(text)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+function getNearbyNodes(latlng){
+ const nearby = [];
+ nodes.forEach(n=>{
+ const marker = markerById[n.key];
+ if(!marker) return;
+ const dist = map.distance(latlng, marker.getLatLng());
+ if(dist <= NEARBY_METERS){
+ nearby.push({ node: n, marker, dist });
+ }
+ });
+ nearby.sort((a,b)=>a.dist - b.dist);
+ return nearby;
+}
+
+function openNearbyPopup(latlng, nearby){
+ const items = nearby.map(item => {
+ const label = item.node.long_name || item.node.short_name || item.node.node_id;
+ return `
+
+
+ ${escapeHtml(label)}
+
+ (${Math.round(item.dist)} m)
+ `;
+ }).join("");
+
+ const html = `
+ `;
+ L.popup().setLatLng(latlng).setContent(html).openOn(map);
+}
+
+function focusNode(nodeId){
+ const marker = markerById[nodeId];
+ const node = nodeMap.get(nodeId);
+ if(!marker || !node) return;
+ window.location.href = `/node/${node.node_id ?? node.id}`;
+}
+
function hashToColor(str){
if(colorMap.has(str)) return colorMap.get(str);
const c = palette[nextColorIndex++ % palette.length];
@@ -404,9 +460,15 @@ function renderNodesOnMap(){
`;
marker.on('click', () => {
+ const nearby = getNearbyNodes(marker.getLatLng());
+ if(nearby.length > 1){
+ openNearbyPopup(marker.getLatLng(), nearby);
+ return;
+ }
onNodeClick(node);
marker.bindPopup(popup).openPopup();
});
+ marker.popupHtml = popup;
});
setTimeout(() => applyTranslationsMap(), 50);
diff --git a/meshview/templates/node.html b/meshview/templates/node.html
index b71576e..6142a67 100644
--- a/meshview/templates/node.html
+++ b/meshview/templates/node.html
@@ -341,31 +341,10 @@
-
Coverage Help
-
- Observed Settings
-
-
-
-
-
@@ -664,8 +643,6 @@ let currentPacketRows = [];
let map, markers = {};
let coverageLayer = null;
-let observedCoverageLayer = null;
-let observedControlsVisible = false;
let chartData = {}, neighborData = { ids:[], names:[], snrs:[] };
let fromNodeId = new URLSearchParams(window.location.search).get("from_node_id");
@@ -741,7 +718,6 @@ async function loadNodeInfo(){
node.last_long ? (node.last_long / 1e7).toFixed(6) : "—";
const coverageBtn = document.getElementById("toggleCoverageBtn");
const coverageHelp = document.getElementById("coverageHelpLink");
- const observedCoverageBtn = document.getElementById("toggleObservedCoverageBtn");
if (coverageBtn) {
const hasLocation = Boolean(node.last_lat && node.last_long);
coverageBtn.disabled = !hasLocation;
@@ -750,21 +726,10 @@ async function loadNodeInfo(){
: (nodeTranslations.location_required || "Location required for coverage");
coverageBtn.style.display = hasLocation ? "" : "none";
}
- if (observedCoverageBtn) {
- const hasLocation = Boolean(node.last_lat && node.last_long);
- observedCoverageBtn.disabled = !hasLocation;
- observedCoverageBtn.title = hasLocation
- ? ""
- : (nodeTranslations.location_required || "Location required for coverage");
- observedCoverageBtn.style.display = hasLocation ? "" : "none";
- }
if (coverageHelp) {
const hasLocation = Boolean(node.last_lat && node.last_long);
coverageHelp.style.display = hasLocation ? "" : "none";
}
- if (!(node.last_lat && node.last_long)) {
- setObservedControlsVisible(false);
- }
let lastSeen = "—";
if (node.last_seen_us) {
@@ -864,10 +829,6 @@ async function toggleCoverage() {
coverageLayer = null;
return;
}
- if (observedCoverageLayer) {
- map.removeLayer(observedCoverageLayer);
- observedCoverageLayer = null;
- }
const nodeId = currentNode?.node_id || fromNodeId;
if (!nodeId) return;
@@ -897,77 +858,6 @@ async function toggleCoverage() {
}
}
-async function toggleObservedCoverage() {
- if (!map) initMap();
-
- if (observedCoverageLayer) {
- map.removeLayer(observedCoverageLayer);
- observedCoverageLayer = null;
- setObservedControlsVisible(false);
- return;
- }
- if (coverageLayer) {
- map.removeLayer(coverageLayer);
- coverageLayer = null;
- }
-
- const nodeId = currentNode?.node_id || fromNodeId;
- if (!nodeId) return;
-
- try {
- setObservedControlsVisible(true);
- const maxHops = getObservedNumber("observedMaxHops", 1, 0, 10);
- const bearingStep = getObservedNumber("observedBearingStep", 5, 1, 90);
- const packetsLimit = getObservedNumber("observedPacketsLimit", 50, 1, 1000);
- const res = await fetch(
- `/api/coverage_observed/${encodeURIComponent(nodeId)}?max_hops=${maxHops}&bearing_step=${bearingStep}&packets_limit=${packetsLimit}`
- );
- if (!res.ok) {
- console.error("Observed coverage request failed", res.status);
- return;
- }
- const data = await res.json();
- if (!data.perimeter || data.perimeter.length < 3) {
- console.warn("Observed coverage perimeter missing or too small");
- return;
- }
- observedCoverageLayer = L.polygon(data.perimeter, {
- color: "#17a2b8",
- weight: 3,
- opacity: 1.0,
- fillColor: "#000000",
- fillOpacity: 0.1
- }).addTo(map);
- map.fitBounds(observedCoverageLayer.getBounds(), { padding: [20, 20] });
- map.invalidateSize();
- } catch (err) {
- console.error("Observed coverage request failed", err);
- }
-}
-
-function setObservedControlsVisible(show) {
- const controls = document.getElementById("observedCoverageControls");
- if (!controls) return;
- observedControlsVisible = show;
- controls.style.display = show ? "" : "none";
-}
-
-function getObservedNumber(id, fallback, min, max) {
- const el = document.getElementById(id);
- if (!el) return fallback;
- const num = Number(el.value);
- if (Number.isNaN(num)) return fallback;
- return Math.max(min, Math.min(max, num));
-}
-
-async function refreshObservedCoverage() {
- if (!observedCoverageLayer) {
- await toggleObservedCoverage();
- return;
- }
- await toggleObservedCoverage();
- await toggleObservedCoverage();
-}
function hideMap(){
const mapDiv = document.getElementById("map");
diff --git a/meshview/web_api/api.py b/meshview/web_api/api.py
index 4cfc88b..08158ea 100644
--- a/meshview/web_api/api.py
+++ b/meshview/web_api/api.py
@@ -1152,118 +1152,3 @@ async def api_coverage(request):
return web.json_response(
{"mode": "heatmap", "min_dbm": min_dbm, "max_dbm": max_dbm, "points": points}
)
-
-
-@routes.get("/api/coverage_observed/{node_id}")
-async def api_coverage_observed(request):
- try:
- node_id = int(request.match_info["node_id"], 0)
- except (KeyError, ValueError):
- return web.json_response({"error": "Invalid node_id"}, status=400)
-
- try:
- max_hops = int(request.query.get("max_hops", "1"))
- except ValueError:
- return web.json_response({"error": "max_hops must be an integer"}, status=400)
-
- try:
- packets_limit = int(request.query.get("packets_limit", "50"))
- if packets_limit <= 0:
- raise ValueError
- except ValueError:
- return web.json_response({"error": "packets_limit must be a positive integer"}, status=400)
-
- try:
- bearing_step = int(request.query.get("bearing_step", "5"))
- if bearing_step <= 0 or bearing_step > 90:
- raise ValueError
- except ValueError:
- return web.json_response({"error": "bearing_step must be 1-90"}, status=400)
-
- since_days = request.query.get("since_days")
- since_us = None
- if since_days:
- try:
- since_days = int(since_days)
- if since_days > 0:
- since_us = int(
- (datetime.datetime.now(datetime.UTC).timestamp() - since_days * 86400)
- * 1_000_000
- )
- except ValueError:
- return web.json_response({"error": "since_days must be an integer"}, status=400)
-
- node = await store.get_node(node_id)
- if not node or not node.last_lat or not node.last_long:
- return web.json_response({"error": "Node not found or missing location"}, status=404)
-
- src_lat = node.last_lat * 1e-7
- src_lon = node.last_long * 1e-7
-
- bearings = {}
- point_count = 0
-
- async with database.async_session() as session:
- pkt_stmt = (
- select(PacketModel.id)
- .where(PacketModel.from_node_id == node_id)
- .order_by(PacketModel.import_time_us.desc())
- .limit(packets_limit)
- )
- pkt_ids = [row[0] for row in (await session.execute(pkt_stmt)).all()]
- if not pkt_ids:
- return web.json_response(
- {
- "mode": "observed",
- "max_hops": max_hops,
- "bearing_step": bearing_step,
- "packets_limit": packets_limit,
- "points_seen": 0,
- "perimeter": [],
- }
- )
-
- stmt = (
- select(PacketSeenModel, Node)
- .join(Node, Node.node_id == PacketSeenModel.node_id)
- .where(PacketSeenModel.packet_id.in_(pkt_ids))
- .where(Node.last_lat.isnot(None), Node.last_long.isnot(None))
- )
- if since_us is not None:
- stmt = stmt.where(PacketSeenModel.import_time_us > since_us)
-
- result = await session.execute(stmt)
- for seen, gw in result.all():
- if seen.hop_start is None or seen.hop_limit is None:
- continue
- hop_count = seen.hop_start - seen.hop_limit
- if hop_count < 0 or hop_count > max_hops:
- continue
-
- gw_lat = gw.last_lat * 1e-7
- gw_lon = gw.last_long * 1e-7
- dist_km = _haversine_km(src_lat, src_lon, gw_lat, gw_lon)
- if dist_km > OBSERVED_MAX_DISTANCE_KM:
- continue
- bearing = _bearing_deg(src_lat, src_lon, gw_lat, gw_lon)
- bucket = int(bearing // bearing_step) * bearing_step
-
- prev = bearings.get(bucket)
- if prev is None or dist_km > prev["dist_km"]:
- bearings[bucket] = {"lat": gw_lat, "lon": gw_lon, "dist_km": dist_km}
- point_count += 1
-
- perimeter = [
- [v["lat"], v["lon"]] for _, v in sorted(bearings.items(), key=lambda item: item[0])
- ]
-
- return web.json_response(
- {
- "mode": "observed",
- "max_hops": max_hops,
- "bearing_step": bearing_step,
- "packets_limit": packets_limit,
- "points_seen": point_count,
- "perimeter": perimeter,
- }
- )