mirror of
https://github.com/yellowcooln/meshcore-mqtt-live-map.git
synced 2026-04-20 23:23:36 +00:00
Merge pull request #18 from mitchellmoss/realtime-los-drag
Enable realtime LOS drag updates
This commit is contained in:
commit
bfb50bc4f0
14 changed files with 580 additions and 70 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
1.2.6
|
||||
1.3.0
|
||||
|
|
|
|||
|
|
@ -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": [...]`).
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}"
|
||||
|
|
|
|||
|
|
@ -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
13
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.
|
||||
|
|
|
|||
2
howto.md
2
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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue