Merge pull request #18 from mitchellmoss/realtime-los-drag

Enable realtime LOS drag updates
This commit is contained in:
Yellowcooln 2026-02-02 20:32:13 -05:00 committed by GitHub
commit bfb50bc4f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 580 additions and 70 deletions

View file

@ -8,6 +8,7 @@ WEB_PORT=8080
PROD_MODE=false
PROD_TOKEN=change-me
LOS_ELEVATION_URL=https://api.opentopodata.org/v1/srtm90m
LOS_ELEVATION_PROXY_URL=/los/elevations
LOS_SAMPLE_MIN=10
LOS_SAMPLE_MAX=80
LOS_SAMPLE_STEP_METERS=250

View file

@ -1,6 +1,6 @@
# Repository Guidelines
Current version: `1.2.6` (see `VERSIONS.md`).
Current version: `1.3.0` (see `VERSIONS.md`).
## Project Structure & Module Organization
- `backend/app.py` wires FastAPI routes, MQTT lifecycle, and websocket broadcast flow.
@ -17,7 +17,7 @@ Current version: `1.2.6` (see `VERSIONS.md`).
- `docker-compose.yaml` runs the service as `meshmap-live`.
- `data/` stores persisted state (`state.json`), route history (`route_history.jsonl`), role overrides (`device_roles.json`), and optional neighbor overrides (`neighbor_overrides.json`).
- `.env` holds dev runtime settings; `.env.example` mirrors template defaults.
- `VERSION.txt` tracks the current version (now `1.2.6`); append changes in `VERSIONS.md`.
- `VERSION.txt` tracks the current version (now `1.3.0`); append changes in `VERSIONS.md`.
## Build, Test, and Development Commands
- `docker compose up -d --build` rebuilds and restarts the backend (preferred workflow).
@ -76,10 +76,10 @@ Current version: `1.2.6` (see `VERSIONS.md`).
- Route collisions fall back to closest-hop selection and drop hops beyond `ROUTE_MAX_HOP_DISTANCE`.
- `ROUTE_INFRA_ONLY` restricts route lines to repeaters/rooms (companions still show as markers).
- Heatmap shows recent traffic points (TTL controlled).
- LOS tool runs **server-side only** via `/los`, returning the elevation profile + peaks.
- LOS uses `/los/elevations` for client-side realtime updates (with `/los` fallback).
- LOS UI includes peak markers, a relay suggestion marker, elevation profile hover, and map-line hover sync.
- LOS legend items (clear/blocked/peaks/relay) are hidden until the LOS tool is active.
- Mobile LOS supports long-press on nodes (Shift+click on desktop).
- Mobile LOS supports long-press on nodes (Shift+click on desktop); endpoints can be dragged or click-selected and moved via map click.
- MQTT online status uses `mqtt_seen_ts` from `MQTT_ONLINE_TOPIC_SUFFIXES` (default `/status,/packets`); markers get a green outline + popup status.
- `MQTT_ONLINE_FORCE_NAMES` (comma-separated device names) forces selected nodes to always appear MQTT online.
- Service worker fetches navigations with `no-store` to avoid stale UI/env toggles (e.g., radius debug ring).

View file

@ -1,7 +1,7 @@
# Architecture Guide
This document explains how the Mesh Live Map codebase is organized and how the components interact.
Current version: `1.2.6` (see `VERSIONS.md`).
Current version: `1.3.0` (see `VERSIONS.md`).
## High-Level Overview
@ -373,4 +373,4 @@ npx eslint backend/static/app.js
```
Versioning:
- See `VERSIONS.md` for the changelog; `VERSION.txt` mirrors the latest entry (`1.2.6`).
- See `VERSIONS.md` for the changelog; `VERSION.txt` mirrors the latest entry (`1.3.0`).

View file

@ -8,7 +8,7 @@ Thanks for helping improve the MeshCore Live Map. This repo is intentionally lig
3) Verify: `curl -s http://localhost:8080/snapshot`
## Versioning
- Current version: `1.2.6` (see `VERSIONS.md`).
- Current version: `1.3.0` (see `VERSIONS.md`).
- Update `VERSION.txt` when adding features.
- Append a new section to `VERSIONS.md` describing the change set.

View file

@ -1,6 +1,6 @@
# Mesh Live Map
Version: `1.2.6` (see [VERSIONS.md](VERSIONS.md))
Version: `1.3.0` (see [VERSIONS.md](VERSIONS.md))
Live MeshCore traffic map that renders nodes, routes, and activity in real time on a Leaflet map. The backend subscribes to MQTT over WebSockets+TLS or TCP, decodes MeshCore packets with `@michaelhart/meshcore-decoder`, and streams updates to the browser via WebSockets.
@ -32,7 +32,7 @@ Live example sites:
- URL parameters to open the map at a specific view (center, zoom, toggles)
- Node search by name or public key
- Adjustable node size slider (defaults from env, saves locally)
- LOS tool with elevation profile + peak markers and hover sync (Shift+click or longpress nodes)
- LOS tool with elevation profile + peak markers, hover sync, and realtime draggable endpoints (Shift+click or longpress nodes)
- Embeddable metadata (Open Graph/Twitter tags) driven by env vars
- Preview image renders in-bounds device dots for shared links
- Route pruning via neighbor-aware closest-hop selection + max hop distance (configurable)
@ -165,6 +165,7 @@ Map + LOS:
- `MAP_RADIUS_KM` (`0` disables radius filtering; `.env.example` uses `241.4` km ≈ 150mi)
- `MAP_RADIUS_SHOW` (`true` draws the radius debug circle)
- `LOS_ELEVATION_URL` (elevation API for LOS tool)
- `LOS_ELEVATION_PROXY_URL` (server proxy for client-side LOS elevation fetches)
- `LOS_SAMPLE_MIN` / `LOS_SAMPLE_MAX` / `LOS_SAMPLE_STEP_METERS`
- `ELEVATION_CACHE_TTL` (seconds)
- `LOS_PEAKS_MAX` (max peaks shown on LOS profile)
@ -214,9 +215,9 @@ Use it:
- MQTT disconnects are handled; the client will reconnect when the broker returns.
- Live route IDs are observer-aware (`message_hash:receiver_id`) so the same
message seen by multiple MQTT observers does not overwrite active lines.
- Line-of-sight tool: click **LOS tool** and pick two points, or **Shift+click** two nodes to measure LOS between them.
- Line-of-sight tool: click **LOS tool** and pick two points, or **Shift+click** two nodes to measure LOS between them. Drag endpoints or select A/B then click the map to move that point.
- On mobile, longpress a node to select it for LOS.
- LOS runs server-side via `/los` (no client-side elevation fetch).
- LOS elevations are fetched via `/los/elevations` and LOS/relay math runs client-side (with `/los` fallback).
- History tool always loads off (use the button or `history=on` in the URL).
- Peers tool uses route history segments; forced MQTT listeners are excluded from peer lists.
- URL params override stored settings: `lat`, `lon`/`lng`/`long`, `zoom`, `layer`, `history`, `heat`, `labels`, `nodes`, `legend`, `menu`, `units`, `history_filter`.

View file

@ -1 +1 @@
1.2.6
1.3.0

View file

@ -1,5 +1,12 @@
# Versions
## v1.3.0 (02-03-2026)
- LOS tool now supports realtime endpoint dragging with throttled live recompute for smoother interaction (PR #18, credit: https://github.com/mitchellmoss).
- Added elevation fetch proxy endpoint (`/los/elevations`) with frontend caching/backoff to reduce API spam and avoid elevation rate-limit failures while dragging.
- Added `LOS_ELEVATION_PROXY_URL` env/config support so LOS elevation requests can be routed through the backend.
- Fixed LOS point repositioning so you can click/select point A/B and click the map to move that specific point (plus visual selected-point highlight).
- Updated LOS docs and feature notes for the new realtime drag + proxy workflow.
## v1.2.6 (02-02-2026)
- API compatibility update for MeshBuddy and similar clients:
- `/api/nodes` now defaults to a flat payload (`"data": [...]`).

View file

@ -136,6 +136,7 @@ from config import (
PROD_MODE,
PROD_TOKEN,
LOS_ELEVATION_URL,
LOS_ELEVATION_PROXY_URL,
LOS_SAMPLE_MIN,
LOS_SAMPLE_MAX,
LOS_SAMPLE_STEP_METERS,
@ -1587,6 +1588,8 @@ def root(request: Request):
MAP_DEFAULT_LAYER,
"LOS_ELEVATION_URL":
LOS_ELEVATION_URL,
"LOS_ELEVATION_PROXY_URL":
LOS_ELEVATION_PROXY_URL,
"LOS_SAMPLE_MIN":
LOS_SAMPLE_MIN,
"LOS_SAMPLE_MAX":
@ -1975,6 +1978,7 @@ def map_page(request: Request):
"MAP_RADIUS_SHOW": str(MAP_RADIUS_SHOW).lower(),
"MAP_DEFAULT_LAYER": MAP_DEFAULT_LAYER,
"LOS_ELEVATION_URL": LOS_ELEVATION_URL,
"LOS_ELEVATION_PROXY_URL": LOS_ELEVATION_PROXY_URL,
"LOS_SAMPLE_MIN": LOS_SAMPLE_MIN,
"LOS_SAMPLE_MAX": LOS_SAMPLE_MAX,
"LOS_SAMPLE_STEP_METERS": LOS_SAMPLE_STEP_METERS,
@ -2360,6 +2364,38 @@ def line_of_sight(
return response
@app.get("/los/elevations")
def los_elevations(locations: str = ""):
raw = [loc for loc in (locations or "").split("|") if loc.strip()]
if not raw:
return {"status": "ERROR", "error": "missing_locations"}
if len(raw) > 200:
return {"status": "ERROR", "error": "too_many_locations"}
points = []
for loc in raw:
parts = loc.split(",")
if len(parts) != 2:
return {"status": "ERROR", "error": "invalid_location"}
try:
lat = float(parts[0])
lon = float(parts[1])
except (ValueError, TypeError):
return {"status": "ERROR", "error": "invalid_coords"}
normalized = _normalize_lat_lon(lat, lon)
if not normalized:
return {"status": "ERROR", "error": "invalid_coords"}
points.append((normalized[0], normalized[1], 0.0))
elevations, error = _fetch_elevations(points)
if error:
return {"status": "ERROR", "error": error}
return {
"status": "OK",
"results": [{"elevation": round(float(elev), 2)} for elev in elevations],
"provider": LOS_ELEVATION_URL,
}
@app.get("/coverage")
async def get_coverage():
if not COVERAGE_API_URL:

View file

@ -157,6 +157,9 @@ PROD_TOKEN = os.getenv("PROD_TOKEN", "").strip()
LOS_ELEVATION_URL = os.getenv(
"LOS_ELEVATION_URL", "https://api.opentopodata.org/v1/srtm90m"
)
LOS_ELEVATION_PROXY_URL = os.getenv(
"LOS_ELEVATION_PROXY_URL", "/los/elevations"
).strip()
LOS_SAMPLE_MIN = int(os.getenv("LOS_SAMPLE_MIN", "10"))
LOS_SAMPLE_MAX = int(os.getenv("LOS_SAMPLE_MAX", "80"))
LOS_SAMPLE_STEP_METERS = int(os.getenv("LOS_SAMPLE_STEP_METERS", "250"))

View file

@ -130,6 +130,19 @@ const hopLayer = L.layerGroup();
const hopMarkers = new Map(); // route_id -> [markers]
let hopsVisible = false;
const losElevationUrl = config.losElevationUrl || 'https://api.opentopodata.org/v1/srtm90m';
const losElevationProxyUrl = (config.losElevationProxyUrl || '/los/elevations').trim();
const losElevationFetchUrl = (() => {
if (!losElevationUrl) return losElevationProxyUrl;
try {
const parsed = new URL(losElevationUrl, window.location.origin);
if (parsed.origin === window.location.origin) {
return parsed.toString();
}
} catch (err) {
// ignore malformed URL and fall back to proxy
}
return losElevationProxyUrl || losElevationUrl;
})();
const losSampleMin = Number(config.losSampleMin) || 10;
const losSampleMax = Number(config.losSampleMax) || 80;
const losSampleStepMeters = Number(config.losSampleStepMeters) || 250;
@ -147,6 +160,11 @@ const heatLayer = heatAvailable ? L.heatLayer([], {
const heatPoints = [];
const HEAT_TTL_MS = 10 * 60 * 1000;
const losLayer = L.layerGroup().addTo(map);
const losPointIcon = L.divIcon({
className: 'los-point-icon',
iconSize: [14, 14],
iconAnchor: [7, 7]
});
const coverageApiUrl = (config.coverageApiUrl || '').trim();
const customLinkUrl = (config.customLinkUrl || '').trim();
const coverageEnabled = Boolean(coverageApiUrl);
@ -161,6 +179,23 @@ let losPeakMarkers = [];
let losHoverMarker = null;
let losActivePeak = null;
let losLocked = false;
let losDragging = false;
let losComputeToken = 0;
let losComputeLast = 0;
let losComputeTimer = null;
const LOS_COMPUTE_THROTTLE_MS = 100;
const LOS_FINAL_DEBOUNCE_MS = 220;
const LOS_ELEVATION_CACHE_PRECISION = 4;
const LOS_ELEVATION_CACHE_STEP = 1 / Math.pow(10, LOS_ELEVATION_CACHE_PRECISION);
const LOS_ELEVATION_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
const LOS_ELEVATION_CACHE_MAX = 4000;
const losElevationCache = new Map();
const LOS_ELEVATION_FETCH_MIN_MS = 500;
const LOS_ELEVATION_BACKOFF_MS = 2500;
let losElevationLastFetchMs = 0;
let losElevationBackoffUntil = 0;
let losLastElevations = null;
let losLastElevationCount = 0;
let lastLosDistance = null;
let lastLosStatusMeta = null;
const losProfile = document.getElementById('los-profile');
@ -194,6 +229,7 @@ let activeRouteDetailsId = null;
let losProfileData = [];
let losProfileMeta = null;
let losPointMarkers = [];
let losSelectedPointIndex = null;
const storedLosHeightA = parseNumberParam(localStorage.getItem('meshmapLosHeightA'));
const storedLosHeightB = parseNumberParam(localStorage.getItem('meshmapLosHeightB'));
let losHeightA = Number.isFinite(storedLosHeightA) ? storedLosHeightA : 0;
@ -1410,21 +1446,22 @@ function clearLos() {
losSuggestion = null;
losLocked = false;
lastLosStatusMeta = null;
if (losComputeTimer) {
clearTimeout(losComputeTimer);
losComputeTimer = null;
}
losComputeToken += 1;
losComputeLast = 0;
losLayer.clearLayers();
setLosStatus('');
clearLosProfile();
clearLosPeaks();
clearLosHoverMarker();
losSelectedPointIndex = null;
losPointMarkers = [];
if (keptPoint) {
losPoints = [keptPoint];
const marker = L.circleMarker(keptPoint, {
radius: 5,
color: '#fbbf24',
fillColor: '#fbbf24',
fillOpacity: 0.9,
weight: 2
}).addTo(losLayer);
const marker = createLosPointMarker(keptPoint, 0);
losPointMarkers.push(marker);
setLosStatus('LOS: select second point');
}
@ -1823,28 +1860,14 @@ function renderLosPeaks(peaks) {
});
}
async function runLosCheckServer(a, b) {
const heightA = Number.isFinite(losHeightA) ? losHeightA : 0;
const heightB = Number.isFinite(losHeightB) ? losHeightB : 0;
const params = new URLSearchParams({
lat1: a.lat.toFixed(6),
lon1: a.lng.toFixed(6),
lat2: b.lat.toFixed(6),
lon2: b.lng.toFixed(6),
h1: heightA.toFixed(2),
h2: heightB.toFixed(2),
});
const res = await fetch(`/los?${params.toString()}`);
const data = await res.json();
if (!data.ok) {
lastLosStatusMeta = null;
setLosStatus(`LOS: ${data.error || 'failed'}`);
if (losLine) {
losLine.setStyle({ color: '#9ca3af', weight: 4, opacity: 0.8, dashArray: '6 10' });
function applyLosResult(data) {
if (!data || !data.ok) return false;
if (Array.isArray(data.profile) && data.profile.length > 1) {
const terrain = data.profile.map(item => Number(item[1]));
if (terrain.every(val => Number.isFinite(val))) {
losLastElevations = terrain;
losLastElevationCount = terrain.length;
}
clearLosProfile();
clearLosPeaks();
return false;
}
const blocked = data.blocked;
const meters = data.distance_m != null ? Number(data.distance_m) : null;
@ -1889,9 +1912,87 @@ async function runLosCheckServer(a, b) {
dashArray: blocked ? '4 10' : null
});
}
clearLosProfileHover();
return true;
}
async function runLosCheckClient(a, b, options = {}) {
const heightA = Number.isFinite(losHeightA) ? losHeightA : 0;
const heightB = Number.isFinite(losHeightB) ? losHeightB : 0;
const distanceMeters = haversineMeters(a.lat, a.lng, b.lat, b.lng);
if (distanceMeters <= 0) {
return { ok: false, error: 'invalid_distance' };
}
const points = sampleLosPoints(a.lat, a.lng, b.lat, b.lng);
const elevationResponse = await fetchElevations(points, options);
if (!elevationResponse.ok) {
return {
ok: false,
error: elevationResponse.error || 'elevation_failed',
rateLimited: elevationResponse.rateLimited === true
};
}
const elevations = elevationResponse.elevations || [];
if (elevations.length !== points.length) {
return { ok: false, error: 'elevation_failed:unexpected_length' };
}
if (!elevationResponse.approximate || !losLastElevations) {
losLastElevations = elevations.slice();
losLastElevationCount = elevations.length;
}
const adjusted = elevations.slice();
const startElev = elevations[0] + heightA;
const endElev = elevations[elevations.length - 1] + heightB;
adjusted[0] = startElev;
adjusted[adjusted.length - 1] = endElev;
const maxObstruction = losMaxObstruction(points, adjusted, 0, points.length - 1);
const blocked = maxObstruction > 0;
const suggestion = blocked ? findLosSuggestion(points, adjusted) : null;
const round2 = (value) => Number(value.toFixed(2));
const profile = points.map((point, idx) => {
const lineElev = startElev + (endElev - startElev) * point.t;
return [
round2(distanceMeters * point.t),
round2(elevations[idx]),
round2(lineElev)
];
});
return {
ok: true,
blocked,
max_obstruction_m: round2(maxObstruction),
distance_m: round2(distanceMeters),
profile,
peaks: findLosPeaks(points, elevations, distanceMeters),
suggested: suggestion,
approximate: elevationResponse.approximate === true,
rateLimited: elevationResponse.rateLimited === true
};
}
async function fetchLosServerResult(a, b) {
const heightA = Number.isFinite(losHeightA) ? losHeightA : 0;
const heightB = Number.isFinite(losHeightB) ? losHeightB : 0;
const params = new URLSearchParams({
lat1: a.lat.toFixed(6),
lon1: a.lng.toFixed(6),
lat2: b.lat.toFixed(6),
lon2: b.lng.toFixed(6),
h1: heightA.toFixed(2),
h2: heightB.toFixed(2),
});
try {
const res = await fetch(`/los?${params.toString()}`);
const data = await res.json();
if (!data.ok) {
return { ok: false, error: data.error || 'failed' };
}
return data;
} catch (err) {
return { ok: false, error: 'los_server_failed' };
}
}
function resetPropagationRaster() {
if (propagationRaster) {
propagationLayer.removeLayer(propagationRaster);
@ -3011,19 +3112,222 @@ function sampleLosPoints(lat1, lon1, lat2, lon2) {
return points;
}
async function fetchElevations(points) {
if (!losElevationUrl) {
function losElevationCacheKey(lat, lon, precision = LOS_ELEVATION_CACHE_PRECISION) {
const safeLat = Number(lat);
const safeLon = Number(lon);
if (!Number.isFinite(safeLat) || !Number.isFinite(safeLon)) return null;
return `${safeLat.toFixed(precision)},${safeLon.toFixed(precision)}`;
}
function cacheLosElevation(key, value, now) {
if (!key || !Number.isFinite(value)) return;
losElevationCache.set(key, { value, ts: now });
if (losElevationCache.size <= LOS_ELEVATION_CACHE_MAX) return;
const excess = losElevationCache.size - LOS_ELEVATION_CACHE_MAX;
const keys = losElevationCache.keys();
for (let i = 0; i < excess; i += 1) {
const next = keys.next();
if (next.done) break;
losElevationCache.delete(next.value);
}
}
function approximateElevationsFromLast(count) {
if (!losLastElevations || losLastElevations.length < 2) return null;
const source = losLastElevations;
const srcCount = source.length;
const target = new Array(count);
if (srcCount === count) {
for (let i = 0; i < count; i += 1) {
target[i] = source[i];
}
return target;
}
if (count === 1) {
target[0] = source[0];
return target;
}
for (let i = 0; i < count; i += 1) {
const t = i / (count - 1);
const pos = t * (srcCount - 1);
const idx = Math.floor(pos);
const frac = pos - idx;
const a = source[idx];
const b = source[Math.min(srcCount - 1, idx + 1)];
target[i] = a + (b - a) * frac;
}
return target;
}
function flatElevations(count) {
if (count <= 0) return [];
const values = new Array(count);
for (let i = 0; i < count; i += 1) {
values[i] = 0;
}
return values;
}
function getCachedElevation(lat, lon, now, allowNeighbor = false) {
const key = losElevationCacheKey(lat, lon);
if (key) {
const cached = losElevationCache.get(key);
if (cached) {
if (now - cached.ts <= LOS_ELEVATION_CACHE_TTL_MS) {
return { value: cached.value, key };
}
losElevationCache.delete(key);
}
}
if (!allowNeighbor) return null;
let best = null;
for (let dx = -1; dx <= 1; dx += 1) {
for (let dy = -1; dy <= 1; dy += 1) {
if (dx === 0 && dy === 0) continue;
const nLat = Number(lat) + dx * LOS_ELEVATION_CACHE_STEP;
const nLon = Number(lon) + dy * LOS_ELEVATION_CACHE_STEP;
const nKey = losElevationCacheKey(nLat, nLon);
if (!nKey) continue;
const cached = losElevationCache.get(nKey);
if (!cached) continue;
if (now - cached.ts > LOS_ELEVATION_CACHE_TTL_MS) {
losElevationCache.delete(nKey);
continue;
}
const dist = Math.abs(dx) + Math.abs(dy);
if (!best || dist < best.dist) {
best = { value: cached.value, key: nKey, dist };
}
}
}
return best ? { value: best.value, key: best.key } : null;
}
function interpolateElevations(values) {
const count = values.length;
let lastKnown = null;
for (let i = 0; i < count; i += 1) {
if (!Number.isFinite(values[i])) continue;
if (lastKnown != null && i - lastKnown > 1) {
const startVal = values[lastKnown];
const endVal = values[i];
const span = i - lastKnown;
for (let j = lastKnown + 1; j < i; j += 1) {
const t = (j - lastKnown) / span;
values[j] = startVal + (endVal - startVal) * t;
}
}
lastKnown = i;
}
if (lastKnown == null) return false;
// Fill leading/trailing gaps with nearest known value.
let firstKnown = values.findIndex(val => Number.isFinite(val));
if (firstKnown > 0) {
for (let i = 0; i < firstKnown; i += 1) {
values[i] = values[firstKnown];
}
}
if (lastKnown < count - 1) {
for (let i = lastKnown + 1; i < count; i += 1) {
values[i] = values[lastKnown];
}
}
return true;
}
async function fetchElevations(points, options = {}) {
if (!losElevationFetchUrl) {
return { ok: false, error: 'no_elevation_url' };
}
const allowNetwork = options.allowNetwork !== false;
const allowApprox = options.allowApprox === true;
const forceNetwork = options.forceNetwork === true;
const now = Date.now();
const results = new Array(points.length);
const missing = [];
points.forEach((point, idx) => {
const cached = getCachedElevation(point.lat, point.lon, now, allowApprox);
if (!cached) {
missing.push({ idx, point, key: null });
return;
}
results[idx] = cached.value;
missing.push({ idx, point, key: cached.key, missing: false });
});
const unresolved = missing.filter(entry => entry.missing !== false && entry.key === null);
if (!unresolved.length) {
return { ok: true, elevations: results };
}
if (!allowNetwork) {
const ok = interpolateElevations(results);
if (!ok) {
const fallback = allowApprox ? approximateElevationsFromLast(points.length) : null;
if (fallback) {
return { ok: true, elevations: fallback, approximate: true, fallback: true };
}
if (allowApprox) {
return { ok: true, elevations: flatElevations(points.length), approximate: true, fallback: true };
}
return { ok: false, error: 'elevation_cache_miss' };
}
return { ok: true, elevations: results, approximate: true };
}
if (!forceNetwork) {
if (now < losElevationBackoffUntil) {
const ok = interpolateElevations(results);
if (ok) {
return { ok: true, elevations: results, approximate: true, rateLimited: true };
}
const fallback = allowApprox ? approximateElevationsFromLast(points.length) : null;
if (fallback) {
return { ok: true, elevations: fallback, approximate: true, rateLimited: true, fallback: true };
}
if (allowApprox) {
return { ok: true, elevations: flatElevations(points.length), approximate: true, rateLimited: true, fallback: true };
}
return { ok: false, error: 'elevation_rate_limited', rateLimited: true };
}
if (now - losElevationLastFetchMs < LOS_ELEVATION_FETCH_MIN_MS) {
const ok = interpolateElevations(results);
if (ok) {
return { ok: true, elevations: results, approximate: true, rateLimited: true };
}
const fallback = allowApprox ? approximateElevationsFromLast(points.length) : null;
if (fallback) {
return { ok: true, elevations: fallback, approximate: true, rateLimited: true, fallback: true };
}
if (allowApprox) {
return { ok: true, elevations: flatElevations(points.length), approximate: true, rateLimited: true, fallback: true };
}
return { ok: false, error: 'elevation_rate_limited', rateLimited: true };
}
}
losElevationLastFetchMs = now;
const chunkSize = 100;
for (let start = 0; start < points.length; start += chunkSize) {
const chunk = points.slice(start, start + chunkSize);
const locations = chunk.map(p => `${p.lat},${p.lon}`).join('|');
const url = `${losElevationUrl}?locations=${encodeURIComponent(locations)}`;
const fetchTargets = unresolved;
for (let start = 0; start < fetchTargets.length; start += chunkSize) {
const chunk = fetchTargets.slice(start, start + chunkSize);
const locations = chunk.map(entry => `${entry.point.lat},${entry.point.lon}`).join('|');
const url = new URL(losElevationFetchUrl, window.location.origin);
url.searchParams.set('locations', locations);
let payload;
try {
const res = await fetch(url);
const res = await fetch(url.toString());
if (res.status === 429) {
losElevationBackoffUntil = Date.now() + LOS_ELEVATION_BACKOFF_MS;
const ok = interpolateElevations(results);
if (ok) {
return { ok: true, elevations: results, approximate: true, rateLimited: true };
}
const fallback = allowApprox ? approximateElevationsFromLast(points.length) : null;
if (fallback) {
return { ok: true, elevations: fallback, approximate: true, rateLimited: true, fallback: true };
}
if (allowApprox) {
return { ok: true, elevations: flatElevations(points.length), approximate: true, rateLimited: true, fallback: true };
}
return { ok: false, error: 'elevation_rate_limited', rateLimited: true };
}
payload = await res.json();
} catch (err) {
return { ok: false, error: 'elevation_fetch_failed' };
@ -3036,7 +3340,11 @@ async function fetchElevations(points) {
return { ok: false, error: 'elevation_fetch_failed:unexpected_length' };
}
elevs.forEach((entry, idx) => {
results[start + idx] = Number(entry.elevation);
const elev = Number(entry.elevation);
const target = chunk[idx];
results[target.idx] = elev;
const key = target.key || losElevationCacheKey(target.point.lat, target.point.lon);
cacheLosElevation(key, elev, now);
});
}
if (results.some(val => val == null || Number.isNaN(val))) {
@ -4014,19 +4322,49 @@ function connectWS() {
};
}
async function runLosCheck() {
async function runLosCheck(options = {}) {
if (losPoints.length < 2) return;
const [a, b] = losPoints;
losComputeLast = Date.now();
const token = ++losComputeToken;
const allowNetwork = options.allowNetwork !== false;
const allowApprox = options.allowApprox === true;
setLosStatus('LOS: calculating...');
try {
const distanceMeters = haversineMeters(a.lat, a.lng, b.lat, b.lng);
if (distanceMeters <= 0) {
if (losLine) {
losLine.setStyle({ color: '#9ca3af', weight: 4, opacity: 0.8, dashArray: '6 10' });
}
const clientResult = await runLosCheckClient(a, b, {
allowNetwork,
allowApprox,
forceNetwork: options.forceNetwork === true
});
if (token !== losComputeToken) return;
if (clientResult.ok && applyLosResult(clientResult)) {
return;
}
if (clientResult.error === 'invalid_distance') {
lastLosStatusMeta = null;
setLosStatus('LOS: invalid distance');
return;
}
const ok = await runLosCheckServer(a, b);
if (ok) return;
setLosStatus('LOS: error');
if (!allowNetwork || clientResult.rateLimited) {
if (clientResult.error === 'elevation_cache_miss') {
setLosStatus('LOS: waiting for elevation data');
} else if (clientResult.rateLimited) {
setLosStatus('LOS: rate limited, using cached terrain');
} else {
setLosStatus(`LOS: ${clientResult.error || 'error'}`);
}
return;
}
const serverResult = await fetchLosServerResult(a, b);
if (token !== losComputeToken) return;
if (serverResult.ok && applyLosResult(serverResult)) {
return;
}
lastLosStatusMeta = null;
setLosStatus(`LOS: ${serverResult.error || clientResult.error || 'error'}`);
if (losLine) {
losLine.setStyle({ color: '#9ca3af', weight: 4, opacity: 0.8, dashArray: '6 10' });
}
@ -4034,12 +4372,39 @@ async function runLosCheck() {
clearLosPeaks();
} catch (err) {
console.warn('los failed', err);
lastLosStatusMeta = null;
setLosStatus('LOS: error');
clearLosProfile();
clearLosPeaks();
}
}
let losPendingOptions = null;
function scheduleLosCheck(force = false, options = {}) {
if (losPoints.length < 2) return;
losPendingOptions = options;
if (force) {
if (losComputeTimer) {
clearTimeout(losComputeTimer);
losComputeTimer = null;
}
runLosCheck(options);
return;
}
const now = Date.now();
const elapsed = now - losComputeLast;
const delay = Math.max(0, LOS_COMPUTE_THROTTLE_MS - elapsed);
if (delay === 0) {
runLosCheck(options);
return;
}
if (losComputeTimer) return;
losComputeTimer = setTimeout(() => {
losComputeTimer = null;
runLosCheck(losPendingOptions || {});
}, delay);
}
initialSnapshot();
connectWS();
setStats();
@ -4346,7 +4711,7 @@ const handleLosHeightChange = () => {
// ignore storage failures
}
if (losPoints.length === 2) {
runLosCheck();
scheduleLosCheck(true, { allowNetwork: true, forceNetwork: true });
}
};
if (losHeightAInput) {
@ -4358,20 +4723,87 @@ if (losHeightBInput) {
losHeightBInput.addEventListener('change', handleLosHeightChange);
}
function updateLosLineFromPoints() {
if (!losLine || losPoints.length < 2) return;
losLine.setLatLngs([losPoints[0], losPoints[1]]);
}
function updateLosPointPosition(index, latlng) {
if (index == null || !losPoints[index]) return;
losPoints[index] = latlng;
updateLosLineFromPoints();
if (losLine && losPoints.length >= 2) {
lastLosStatusMeta = null;
losLine.setStyle({ color: '#9ca3af', weight: 4, opacity: 0.8, dashArray: '6 10' });
setLosStatus('LOS: calculating...');
}
}
function setLosSelectedPoint(index) {
if (!Number.isInteger(index) || index < 0 || index >= losPointMarkers.length) {
losSelectedPointIndex = null;
} else {
losSelectedPointIndex = index;
}
losPointMarkers.forEach((pointMarker, idx) => {
const el = pointMarker && pointMarker.getElement ? pointMarker.getElement() : null;
if (!el) return;
el.classList.toggle('selected', losSelectedPointIndex === idx);
});
}
function createLosPointMarker(latlng, index) {
const marker = L.marker(latlng, {
icon: losPointIcon,
draggable: true,
autoPan: false,
zIndexOffset: 450
}).addTo(losLayer);
marker.__losIndex = index;
marker.on('click', (ev) => {
if (ev && ev.originalEvent) {
ev.originalEvent.preventDefault();
ev.originalEvent.stopPropagation();
}
if (typeof L !== 'undefined' && L.DomEvent) {
L.DomEvent.stop(ev);
}
setLosSelectedPoint(index);
setLosStatus(`LOS: selected point ${index === 0 ? 'A' : 'B'} (click map to move or drag point)`);
});
marker.on('dragstart', () => {
const el = marker.getElement();
if (el) el.classList.add('dragging');
setLosSelectedPoint(index);
losDragging = true;
clearLosProfileHover();
});
marker.on('drag', () => {
const next = marker.getLatLng();
updateLosPointPosition(index, next);
scheduleLosCheck(false, { allowNetwork: false, allowApprox: true });
});
marker.on('dragend', () => {
const el = marker.getElement();
if (el) el.classList.remove('dragging');
const next = marker.getLatLng();
updateLosPointPosition(index, next);
losDragging = false;
scheduleLosCheck(true, { allowNetwork: true, forceNetwork: true });
});
marker.on('add', () => setLosSelectedPoint(losSelectedPointIndex));
return marker;
}
function handleLosPoint(latlng) {
if (losLocked || losPoints.length >= 2) {
setLosStatus('LOS: Clear to start a new path');
return;
}
losPoints.push(latlng);
const marker = L.circleMarker(latlng, {
radius: 5,
color: '#fbbf24',
fillColor: '#fbbf24',
fillOpacity: 0.9,
weight: 2
}).addTo(losLayer);
const marker = createLosPointMarker(latlng, losPoints.length - 1);
losPointMarkers.push(marker);
setLosSelectedPoint(losPoints.length - 1);
if (losPoints.length === 1) {
setLosStatus('LOS: select second point');
@ -4391,7 +4823,7 @@ function handleLosPoint(latlng) {
}
});
losLine.on('mouseout', clearLosProfileHover);
runLosCheck();
scheduleLosCheck(true, { allowNetwork: true, forceNetwork: true });
}
}
@ -4784,6 +5216,15 @@ map.on('click', (ev) => {
return;
}
if (losActive) {
if (losLocked && losSelectedPointIndex != null) {
const idx = losSelectedPointIndex;
if (losPointMarkers[idx]) {
losPointMarkers[idx].setLatLng(ev.latlng);
}
updateLosPointPosition(idx, ev.latlng);
scheduleLosCheck(true, { allowNetwork: true, forceNetwork: true });
return;
}
handleLosPoint(ev.latlng);
return;
}

View file

@ -30,6 +30,7 @@
data-map-start-zoom="{{MAP_START_ZOOM}}" data-map-radius-km="{{MAP_RADIUS_KM}}"
data-map-radius-show="{{MAP_RADIUS_SHOW}}" data-map-default-layer="{{MAP_DEFAULT_LAYER}}"
data-prod-mode="{{PROD_MODE}}" data-prod-token="{{PROD_TOKEN}}" data-los-elevation-url="{{LOS_ELEVATION_URL}}"
data-los-elevation-proxy-url="{{LOS_ELEVATION_PROXY_URL}}"
data-los-sample-min="{{LOS_SAMPLE_MIN}}" data-los-sample-max="{{LOS_SAMPLE_MAX}}"
data-los-sample-step-meters="{{LOS_SAMPLE_STEP_METERS}}" data-los-peaks-max="{{LOS_PEAKS_MAX}}"
data-mqtt-online-seconds="{{MQTT_ONLINE_SECONDS}}" data-distance-units="{{DISTANCE_UNITS}}"

View file

@ -483,6 +483,25 @@
box-sizing: border-box;
}
.los-point-icon {
width: 14px;
height: 14px;
border-radius: 50%;
background: #fbbf24;
border: 2px solid #f59e0b;
box-shadow: 0 0 0 2px rgba(15, 23, 42, .5);
cursor: grab;
}
.los-point-icon.dragging {
cursor: grabbing;
transform: scale(1.05);
}
.los-point-icon.selected {
box-shadow: 0 0 0 2px rgba(15, 23, 42, .5), 0 0 0 5px rgba(245, 158, 11, .35);
}
.los-panel.active {
display: block;
}

13
docs.md
View file

@ -1,13 +1,13 @@
# Mesh Map Live: Implementation Notes
This document captures the state of the project and the key changes made so far, so a new Codex session can pick up without losing context.
Current version: `1.2.6` (see `VERSIONS.md`).
Current version: `1.3.0` (see `VERSIONS.md`).
## Overview
This project renders live MeshCore traffic on a Leaflet + OpenStreetMap map. A FastAPI backend subscribes to MQTT (WSS/TLS or TCP), decodes MeshCore packets using `@michaelhart/meshcore-decoder`, and broadcasts device updates and routes over WebSockets to the frontend. Core logic is split into config/state/decoder/LOS/history modules so changes are localized. The UI includes heatmap, LOS tools, map mode toggles, and a 24hour route history layer.
## Versioning
- `VERSION.txt` holds the current version string (`1.2.6`).
- `VERSION.txt` holds the current version string (`1.3.0`).
- `VERSIONS.md` is an append-only changelog by version.
## Key Paths
@ -103,14 +103,15 @@ This project renders live MeshCore traffic on a Leaflet + OpenStreetMap map. A F
- HUD scrollbars are custom styled in Chromium for a cleaner look.
## LOS (Line of Sight) Tool
- LOS runs **server-side only** via `/los` (no client-side elevation fetch).
- LOS elevations are fetched via `/los/elevations` (proxy for `LOS_ELEVATION_URL`) and the LOS/relay/profile math runs client-side for realtime updates (fallbacks still use `/los`).
- UI draws an LOS line (green clear / red blocked), renders an elevation profile, and marks peaks.
- When blocked, the server can return a relay suggestion marker (amber/green).
- When blocked, a relay suggestion marker (amber/green) highlights a potential mid-point.
- Peak markers show coords + elevation and copy coords on click.
- Hovering the profile or the LOS line syncs a cursor tooltip on the profile.
- Hovering the LOS profile also tracks a cursor on the map and highlights nearby peaks.
- LOS legend items (clear/blocked/peaks/relay) are hidden unless the LOS tool is active.
- Shift+click nodes (or longpress on mobile) or click two points on the map to run LOS.
- Shift+click nodes (or longpress on mobile) or click two points on the map to run LOS. Drag endpoints to update LOS in realtime.
- After LOS is locked, click a point marker (A/B) to select it, then click the map to reposition that specific endpoint.
## Device Names + Roles
- Names come from advert payloads or status messages when available.
@ -171,7 +172,7 @@ If routes arent visible:
- UI: route legend, role legend, and improved marker styles.
- Roles now apply to advertised pubkey, not receiver.
- Docker restarts are required after file changes (always run `docker compose up -d --build`).
- LOS is server-side only; elevation profile/peaks are returned by `/los`.
- LOS elevations are proxied via `/los/elevations` and LOS/relay computations run client-side (with `/los` fallback).
- MQTT online indicator (green outline + legend) and configurable online window.
- Filters out `0,0` GPS points from devices, trails, and routes (including string values).
- Added 24h route history storage + history toggle with volume-based colors.

View file

@ -1,7 +1,7 @@
# How-To: MQTT Broker + Live Map
This guide covers two parts: stand up a MeshCore MQTT broker and point the live map at it.
Current version: `1.2.6` (see `VERSIONS.md`).
Current version: `1.3.0` (see `VERSIONS.md`).
## 1) MQTT broker (meshcore-mqtt-broker)