mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-03-04 23:27:46 +01:00
Added visivility on maps of nodes that overlap
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, "&")
|
||||
.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 `
|
||||
<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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user