1
1
forked from iarv/meshview

Added visivility on maps of nodes that overlap

This commit is contained in:
pablorevilla-meshtastic
2026-02-12 11:33:24 -08:00
parent fc44f49f2d
commit 2002e093af
6 changed files with 68 additions and 261 deletions

View File

@@ -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)

View File

@@ -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",

View File

@@ -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",

View File

@@ -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; }
</style>
{% 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 `
<li>
<a href="#" onclick="focusNode(${item.node.key}); return false;">
${escapeHtml(label)}
</a>
<span class="nearby-distance">(${Math.round(item.dist)} m)</span>
</li>`;
}).join("");
const html = `
<div class="nearby-popup">
<div data-translate-lang="nearby_nodes_title">Multiple nodes here</div>
<div data-translate-lang="nearby_nodes_hint">Select a node:</div>
<ul class="nearby-list">${items}</ul>
</div>`;
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);

View File

@@ -341,31 +341,10 @@
<button onclick="toggleCoverage()" id="toggleCoverageBtn" disabled title="Location required for coverage">
<span>📡</span> <span data-translate-lang="toggle_coverage">Predicted Coverage</span>
</button>
<button onclick="toggleObservedCoverage()" id="toggleObservedCoverageBtn" disabled title="Location required for coverage">
<span>🛰</span> <span data-translate-lang="toggle_observed_coverage">Observed Coverage</span>
</button>
<a class="inline-link" id="coverageHelpLink" href="/docs/COVERAGE.md" target="_blank" rel="noopener" data-translate-lang="coverage_help">
Coverage Help
</a>
</div>
<div id="observedCoverageControls" class="node-actions" style="display:none;">
<span data-translate-lang="observed_settings">Observed Settings</span>
<label>
<span data-translate-lang="max_hops">Max Hops</span>
<input id="observedMaxHops" type="number" min="0" max="10" value="1" style="width:60px;">
</label>
<label>
<span data-translate-lang="bearing_step">Bearing Step</span>
<input id="observedBearingStep" type="number" min="1" max="90" value="5" style="width:60px;">
</label>
<label>
<span data-translate-lang="packets_limit">Packets</span>
<input id="observedPacketsLimit" type="number" min="1" max="1000" value="50" style="width:80px;">
</label>
<button onclick="refreshObservedCoverage()" id="refreshObservedCoverageBtn">
<span data-translate-lang="refresh">Refresh</span>
</button>
</div>
<!-- Impersonation Warning -->
<div id="impersonationWarning" class="impersonation-warning" style="display:none;">
@@ -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");

View File

@@ -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,
}
)