diff --git a/.env.example b/.env.example index 08a4ed1..3364d6f 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 6b2cdd3..7ee02ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 690a15a..c54f816 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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`). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a56fafe..e2e28f5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/README.md b/README.md index a0ba4a2..297121c 100644 --- a/README.md +++ b/README.md @@ -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 long‑press nodes) +- LOS tool with elevation profile + peak markers, hover sync, and realtime draggable endpoints (Shift+click or long‑press 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, long‑press 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`. diff --git a/VERSION.txt b/VERSION.txt index 3c43790..f0bb29e 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.2.6 +1.3.0 diff --git a/VERSIONS.md b/VERSIONS.md index 74baf2d..97e44e2 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -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": [...]`). diff --git a/backend/app.py b/backend/app.py index 28e0e2a..4ad77cb 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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: diff --git a/backend/config.py b/backend/config.py index c4689ee..13d60b8 100644 --- a/backend/config.py +++ b/backend/config.py @@ -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")) diff --git a/backend/static/app.js b/backend/static/app.js index 350520f..0de022c 100644 --- a/backend/static/app.js +++ b/backend/static/app.js @@ -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; } diff --git a/backend/static/index.html b/backend/static/index.html index 9096768..b2f07c0 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -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}}" diff --git a/backend/static/styles.css b/backend/static/styles.css index 09dbb71..6f5c3eb 100644 --- a/backend/static/styles.css +++ b/backend/static/styles.css @@ -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; } diff --git a/docs.md b/docs.md index da225e8..bc026b9 100644 --- a/docs.md +++ b/docs.md @@ -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 24‑hour 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 long‑press on mobile) or click two points on the map to run LOS. +- Shift+click nodes (or long‑press 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 aren’t 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. diff --git a/howto.md b/howto.md index e543ea0..33173ba 100644 --- a/howto.md +++ b/howto.md @@ -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)