meshcore-mqtt-live-map/backend/static/app.js
2026-04-17 10:57:10 -04:00

7576 lines
254 KiB
JavaScript

window.__meshmapStarted = true;
const config = document.body ? document.body.dataset : {};
const queryParams = new URLSearchParams(window.location.search);
const parseNumberParam = (value) => {
if (value == null) return null;
const str = String(value).trim();
if (!str) return null;
const num = Number(str);
return Number.isFinite(num) ? num : null;
};
const clampNumber = (value, min, max) => Math.min(max, Math.max(min, value));
const parseBoolParam = (value) => {
if (value == null) return null;
const str = String(value).trim().toLowerCase();
if (!str) return null;
if (['1', 'true', 'yes', 'on'].includes(str)) return true;
if (['0', 'false', 'no', 'off'].includes(str)) return false;
if (!Number.isNaN(Number(str))) return Number(str) > 0;
return null;
};
const escapeHtmlAttr = (value) => String(value ?? '')
.replaceAll('&', '&')
.replaceAll('"', '"')
.replaceAll("'", ''')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
const parseHistoryFilterParam = (value) => {
if (value == null) return null;
const str = String(value).trim().toLowerCase();
if (!str) return null;
if (str === 'all' || str === '0') return 0;
if (str === 'blue' || str === '1') return 1;
if (str === 'yellow' || str === '2') return 2;
if (str === 'yellowred' || str === 'yellow+red' || str === 'yellow-red' || str === '3') return 3;
if (str === 'red' || str === '4') return 4;
return null;
};
const queryLat = parseNumberParam(queryParams.get('lat') ?? queryParams.get('latitude'));
const queryLon = parseNumberParam(queryParams.get('lon') ?? queryParams.get('lng') ?? queryParams.get('long') ?? queryParams.get('longitude'));
const queryZoom = parseNumberParam(queryParams.get('zoom'));
const queryLayer = String(queryParams.get('layer') || queryParams.get('map') || '').toLowerCase();
const queryHistoryVisible = parseBoolParam(queryParams.get('history'));
const queryHeatVisible = parseBoolParam(queryParams.get('heat'));
const queryCoverageVisible = parseBoolParam(queryParams.get('coverage'));
const queryWeatherVisible = parseBoolParam(queryParams.get('weather'));
const queryWeatherRadarVisible = parseBoolParam(
queryParams.get('weather_radar') || queryParams.get('radar')
);
const queryWeatherWindVisible = parseBoolParam(
queryParams.get('weather_wind') || queryParams.get('wind')
);
const WEATHER_RADAR_LAYER_STORAGE_KEY = 'meshmapWeatherRadarLayerEnabled';
const WEATHER_WIND_LAYER_STORAGE_KEY = 'meshmapWeatherWindLayerEnabled';
const queryLabelsVisible = parseBoolParam(queryParams.get('labels'));
const queryNodesVisible = parseBoolParam(queryParams.get('nodes'));
const queryLegendVisible = parseBoolParam(queryParams.get('legend'));
const queryMenuVisible = parseBoolParam(
queryParams.get('menu') || queryParams.get('hud') || queryParams.get('panel')
);
const queryUnits = String(queryParams.get('units') || queryParams.get('unit') || '').toLowerCase();
const queryHistoryFilter = parseHistoryFilterParam(
queryParams.get('history_filter') || queryParams.get('historyFilter') || queryParams.get('historyfilter')
);
const initialUpdateAvailable = parseBoolParam(config.updateAvailable);
const initialUpdateLocal = (config.updateLocal || '').trim();
const initialUpdateRemote = (config.updateRemote || '').trim();
const reportError = typeof window.__meshmapReportError === 'function'
? window.__meshmapReportError
: (message) => console.warn(message);
const appVersion = (config.appVersion || 'dev').trim() || 'dev';
console.info(`[meshmap] version ${appVersion}`);
let serverTimeOffsetMs = 0;
function setServerTimeOffset(serverTimeSeconds) {
const serverMs = Number(serverTimeSeconds) * 1000;
if (!Number.isFinite(serverMs) || serverMs <= 0) return;
serverTimeOffsetMs = serverMs - Date.now();
}
function getServerNowMs() {
return Date.now() + serverTimeOffsetMs;
}
const envStartLat = parseFloat(config.mapStartLat);
const envStartLon = parseFloat(config.mapStartLon);
const envStartZoom = Number(config.mapStartZoom);
const defaultLat = Number.isFinite(envStartLat) ? envStartLat : 42.3601;
const defaultLon = Number.isFinite(envStartLon) ? envStartLon : -71.1500;
const defaultZoom = Number.isFinite(envStartZoom) && envStartZoom > 0 ? envStartZoom : 10;
const mapStartLat = Number.isFinite(queryLat) ? queryLat : defaultLat;
const mapStartLon = Number.isFinite(queryLon) ? queryLon : defaultLon;
const mapStartZoom = Number.isFinite(queryZoom) && queryZoom > 0 ? queryZoom : defaultZoom;
const mapRadiusKm = Number(config.mapRadiusKm) || 0;
const mapRadiusShow = String(config.mapRadiusShow).toLowerCase() === 'true';
const mapBoundaryMode = String(config.mapBoundaryMode || 'radius').toLowerCase();
const mapBoundaryShow = String(config.mapBoundaryShow).toLowerCase() === 'true';
const mapBoundaryName = String(config.mapBoundaryName || '').trim();
const mapBoundaryDataEl = document.getElementById('map-boundary-data');
let mapBoundaryPoints = [];
if (mapBoundaryDataEl) {
try {
const parsed = JSON.parse(mapBoundaryDataEl.textContent || '[]');
if (Array.isArray(parsed)) {
mapBoundaryPoints = parsed
.map((pt) => Array.isArray(pt) && pt.length >= 2 ? [Number(pt[0]), Number(pt[1])] : null)
.filter((pt) => Array.isArray(pt) && Number.isFinite(pt[0]) && Number.isFinite(pt[1]));
}
} catch (_err) {
mapBoundaryPoints = [];
}
}
let baseLayer = (config.mapDefaultLayer || 'light').toLowerCase();
const validLayers = new Set(['dark', 'topo', 'light']);
if (validLayers.has(queryLayer)) {
baseLayer = queryLayer;
}
if (!validLayers.has(baseLayer)) {
baseLayer = 'light';
}
const map = L.map('map', { zoomControl: false, preferCanvas: true }).setView([mapStartLat, mapStartLon], mapStartZoom);
const vectorRenderer = L.canvas({ padding: 0.3 });
const animatedLineRenderer = L.svg({ padding: 0.3 });
L.control.zoom({ position: 'bottomright' }).addTo(map);
if (!map.getPane('radarPane')) {
map.createPane('radarPane');
}
const radarPane = map.getPane('radarPane');
if (radarPane) {
radarPane.style.zIndex = '320';
radarPane.style.pointerEvents = 'none';
}
if (!map.getPane('weatherWindPane')) {
map.createPane('weatherWindPane');
}
const weatherWindPane = map.getPane('weatherWindPane');
if (weatherWindPane) {
weatherWindPane.style.zIndex = '330';
weatherWindPane.style.pointerEvents = 'none';
}
if (!map.getPane('peerPane')) {
map.createPane('peerPane');
}
const peerPane = map.getPane('peerPane');
if (peerPane) {
peerPane.style.zIndex = '340';
peerPane.style.pointerEvents = 'none';
}
if (!map.getPane('arcadePane')) {
map.createPane('arcadePane');
}
const arcadePane = map.getPane('arcadePane');
if (arcadePane) {
arcadePane.style.zIndex = '345';
arcadePane.style.pointerEvents = 'none';
}
const lightTiles = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; OpenStreetMap contributors'
}).addTo(map);
const darkTiles = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
attribution: '&copy; OpenStreetMap contributors &copy; CARTO'
});
const topoTiles = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
maxZoom: 17,
attribution: '&copy; OpenStreetMap contributors &copy; OpenTopoMap'
});
let mapRadiusCircle = null;
const activeBoundaryShow = mapBoundaryShow || mapRadiusShow;
if (mapBoundaryMode === 'radius' && activeBoundaryShow && mapRadiusKm > 0) {
mapRadiusCircle = L.circle([mapStartLat, mapStartLon], {
radius: mapRadiusKm * 1000.0,
color: '#38bdf8',
weight: 2,
dashArray: '6 8',
fillColor: '#38bdf8',
fillOpacity: 0.05,
interactive: false
}).addTo(map);
}
let mapBoundaryPolygon = null;
if (mapBoundaryMode === 'polygon' && activeBoundaryShow && mapBoundaryPoints.length >= 3) {
mapBoundaryPolygon = L.polygon(mapBoundaryPoints, {
color: '#38bdf8',
weight: 2,
dashArray: '6 8',
fillColor: '#38bdf8',
fillOpacity: 0.04,
interactive: false
}).addTo(map);
if (mapBoundaryName) {
mapBoundaryPolygon.bindTooltip(mapBoundaryName, {
permanent: false,
direction: 'center',
sticky: false,
opacity: 0.9
});
}
}
const storedLayer = localStorage.getItem('meshmapBaseLayer');
if (!validLayers.has(queryLayer) && (storedLayer === 'dark' || storedLayer === 'topo' || storedLayer === 'light')) {
baseLayer = storedLayer;
}
const prodMode = String(config.prodMode).toLowerCase() === 'true';
const apiToken = config.prodToken || '';
const qrCodeButtonEnabled =
String(config.qrCodeButtonEnabled).toLowerCase() === 'true';
const tokenHeaders = () => (prodMode && apiToken ? { 'x-access-token': apiToken } : {});
const withToken = (path) => {
if (!prodMode || !apiToken) return path;
const url = new URL(path, window.location.origin);
url.searchParams.set('token', apiToken);
return `${url.pathname}${url.search}`;
};
const markers = new Map(); // device_id -> Leaflet marker
const polylines = new Map(); // device_id -> Leaflet polyline
const markerLayer = L.layerGroup().addTo(map);
const trailLayer = L.layerGroup().addTo(map);
let nodesVisible = true;
const routeLines = new Map(); // route_id -> { line, timeout }
const deviceMeta = new Map(); // device_id -> { lat, lon, name }
const historyLines = new Map(); // edge_id -> { line, count }
const historyCache = new Map(); // edge_id -> raw edge data
let mqttPresenceKnown = false;
let mqttConnectedTotal = 0;
const historyLayer = L.layerGroup();
const peerLayer = L.layerGroup();
const peerLines = new Map(); // peer_id -> line
const routeLayer = L.layerGroup().addTo(map);
const arcadeLayer = L.layerGroup();
const hopLayer = L.layerGroup();
const hopMarkers = new Map(); // route_id -> [markers]
let hopsVisible = false;
let arcadeModeEnabled = false;
const FLOW_MIN_DURATION_MS = 2800;
const FLOW_MAX_DURATION_MS = 9000;
let arcadeAnimFrame = null;
function routeStyleForDisplay(payloadType, routeMode, arcadeMode = false) {
const isFanout = routeMode === 'fanout';
const payloadNum = Number(payloadType);
const isAdvert = payloadNum === 4;
const isTrace = payloadNum === 8 || payloadNum === 9;
const isMessage = payloadNum === 2 || payloadNum === 5;
const style = {
color: isAdvert
? '#2ecc71'
: (isTrace ? '#ff7a1a' : (isMessage ? '#2b8cff' : (isFanout ? '#2b8cff' : '#ff7a1a'))),
weight: isFanout ? 4 : 5,
opacity: isFanout ? 0.85 : 0.9,
lineCap: 'butt',
lineJoin: 'miter',
};
if (isAdvert) {
style.dashArray = '2 10';
} else if (isMessage) {
style.dashArray = '6 12';
} else if (isTrace) {
style.dashArray = '8 14';
} else if (!isFanout) {
style.dashArray = '8 14';
}
if (arcadeMode) {
style.weight = isFanout ? 3 : 2.5;
style.opacity = 0.42;
style.lineCap = 'round';
style.lineJoin = 'round';
if (isAdvert) {
style.dashArray = '2 16';
} else if (isMessage) {
style.dashArray = '2 18';
} else if (isTrace) {
style.dashArray = '2 22';
} else {
style.dashArray = '2 20';
}
}
return style;
}
function setArcadeModeEnabled(enabled, persist = true) {
arcadeModeEnabled = Boolean(enabled);
if (arcadeModeEnabled && nodesVisible) {
if (!map.hasLayer(routeLayer)) {
routeLayer.addTo(map);
}
if (!map.hasLayer(arcadeLayer)) {
arcadeLayer.addTo(map);
}
routeLines.forEach((entry, routeId) => {
if (entry && Array.isArray(entry.routePoints)) {
syncArcadeForRoute(routeId, entry.routePoints);
}
if (entry && entry.line) {
entry.line.setStyle(
routeStyleForDisplay(entry.payloadType, entry.routeMode, true)
);
const lineEl = entry.line.getElement();
if (lineEl) {
lineEl.classList.remove('route-animated');
}
}
});
startArcadeLoop();
} else {
routeLines.forEach((_entry, routeId) => removeArcadeMarker(routeId));
routeLines.forEach((entry) => {
if (!entry || !entry.line) return;
entry.line.setStyle(
routeStyleForDisplay(entry.payloadType, entry.routeMode, false)
);
const lineEl = entry.line.getElement();
if (lineEl) {
lineEl.classList.add('route-animated');
}
});
if (map.hasLayer(arcadeLayer)) {
map.removeLayer(arcadeLayer);
}
stopArcadeLoop();
}
}
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;
const losCurvatureEnabled = parseBoolParam(config.losCurvatureEnabled) ?? true;
const losCurvatureFactor = (() => {
const parsed = Number(config.losCurvatureFactor);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
return 1.333333;
})();
const losPeaksMax = Number(config.losPeaksMax) || 4;
const mqttOnlineSeconds = Number(config.mqttOnlineSeconds) || 300;
const mqttOnlineStatusTtlSeconds = Number(config.mqttOnlineStatusTtlSeconds) || mqttOnlineSeconds;
const mqttOnlineInternalTtlSeconds = Number(config.mqttOnlineInternalTtlSeconds) || mqttOnlineSeconds;
const defaultDistanceUnits = config.distanceUnits || 'km';
const heatAvailable = typeof L.heatLayer === 'function';
const heatLayer = heatAvailable ? L.heatLayer([], {
radius: 28,
blur: 22,
minOpacity: 0.2,
maxZoom: 16,
gradient: { 0.2: '#fbbf24', 0.5: '#f97316', 0.8: '#ef4444', 1.0: '#b91c1c' }
}) : null;
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 packetAnalyzerUrl = (config.packetAnalyzerUrl || '').trim();
const coverageEnabled = Boolean(coverageApiUrl);
const coverageLayer = L.layerGroup();
let coverageVisible = false;
let coverageData = null;
let meshMapperCoverageRects = [];
let meshMapperCoverageSource = null;
let meshMapperCoverageExpanded = [];
let coverageProvider = '';
let coverageRegion = '';
let coverageAttributionHtml = '';
const radarLayerGroup = L.layerGroup();
let radarLayer = null;
let radarVisible = false;
let radarLoading = false;
let radarPreloading = false;
let radarPreloadTimeout = null;
let radarFramePath = '';
let radarFrameHost = 'https://tilecache.rainviewer.com';
let radarRefreshTimer = null;
let radarRequestSeq = 0;
let radarLayerBoundsKey = '';
let radarCountryBounds = null;
let radarCountryBoundsKey = '';
let radarCountryBoundsPromise = null;
const RADAR_REFRESH_MS = 5 * 60 * 1000;
const RADAR_META_URLS = [
'https://tilecache.rainviewer.com/api/weather-maps.json',
'https://api.rainviewer.com/public/weather-maps.json'
];
const RADAR_MAX_NATIVE_ZOOM = 7;
const weatherRadarEnabled = String(config.weatherRadarEnabled).toLowerCase() !== 'false';
const weatherRadarCountryBoundsEnabled = String(config.weatherRadarCountryBoundsEnabled).toLowerCase() === 'true';
const weatherRadarCountryLookupUrl = (config.weatherRadarCountryLookupUrl || '/weather/radar/country-bounds').trim();
const WEATHER_RADAR_COUNTRY_CACHE_KEY = 'meshmapWeatherRadarCountryBounds';
const weatherWindLayer = L.layerGroup();
let weatherWindRequestSeq = 0;
let weatherWindRefreshTimer = null;
let weatherWindMoveTimer = null;
let weatherWindLoading = false;
const weatherWindEnabled = String(config.weatherWindEnabled).toLowerCase() !== 'false';
const weatherWindApiUrl = (config.weatherWindApiUrl || 'https://api.open-meteo.com/v1/forecast').trim();
const weatherWindGridSizeRaw = Number(config.weatherWindGridSize);
const weatherWindGridSize = Number.isFinite(weatherWindGridSizeRaw)
? clampNumber(Math.round(weatherWindGridSizeRaw), 1, 5)
: 3;
const weatherWindRefreshSecondsRaw = Number(config.weatherWindRefreshSeconds);
const weatherWindRefreshMs = (Number.isFinite(weatherWindRefreshSecondsRaw)
? clampNumber(Math.round(weatherWindRefreshSecondsRaw), 30, 1800)
: 180) * 1000;
const weatherAvailable = weatherRadarEnabled || weatherWindEnabled;
let weatherRadarLayerEnabled = weatherRadarEnabled;
let weatherWindLayerEnabled = weatherWindEnabled;
let losActive = false;
let losPoints = [];
let losLines = [];
let losSegmentMeta = [];
let losActiveSegmentIndex = null;
let losSuggestion = null;
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');
const losProfileSvg = document.getElementById('los-profile-svg');
const losProfileTooltip = document.getElementById('los-profile-tooltip');
const losLegendGroup = document.getElementById('legend-los-group');
const losClearButton = document.getElementById('los-clear');
const losRemoveLastButton = document.getElementById('los-remove-last');
const losPanel = document.getElementById('los-panel');
const losPanelCollapse = document.getElementById('los-panel-collapse');
const losHeightAInput = document.getElementById('los-height-a');
const losHeightBInput = document.getElementById('los-height-b');
const propPanel = document.getElementById('prop-panel');
const propPanelCollapse = document.getElementById('prop-panel-collapse');
const historyPanel = document.getElementById('history-panel');
const historyLegendGroup = document.getElementById('legend-history-group');
const coverageLegendGroup = document.getElementById('legend-coverage-group');
const historyPanelLabel = document.getElementById('history-panel-label');
const historyPanelCollapse = document.getElementById('history-panel-collapse');
const weatherPanel = document.getElementById('weather-panel');
const weatherHideButton = document.getElementById('weather-hide');
const weatherRadarLayerToggle = document.getElementById('weather-radar-layer-toggle');
const weatherWindLayerToggle = document.getElementById('weather-wind-layer-toggle');
const peersPanel = document.getElementById('peers-panel');
const peersPanelCollapse = document.getElementById('peers-panel-collapse');
const peersStatus = document.getElementById('peers-status');
const peersMeta = document.getElementById('peers-meta');
const peersIn = document.getElementById('peers-in');
const peersOut = document.getElementById('peers-out');
const peersToggle = document.getElementById('peers-toggle');
const peersClear = document.getElementById('peers-clear');
const routeDetailsPanel = document.getElementById('route-details-panel');
const routeDetailsTitle = document.getElementById('route-details-title');
const routeDetailsContent = document.getElementById('route-details-content');
const routeDetailsTotal = document.getElementById('route-details-total');
const routeDetailsCollapse = document.getElementById('route-details-collapse');
const qrModal = document.getElementById('qr-modal');
const qrModalBackdrop = document.getElementById('qr-modal-backdrop');
const qrModalClose = document.getElementById('qr-modal-close');
const qrModalTitle = document.getElementById('qr-modal-title');
const qrModalLabel = document.getElementById('qr-modal-label');
const qrModalImage = document.getElementById('qr-modal-image');
let activeRouteDetailsMeta = null;
let activeRouteDetailsId = null;
let losProfileData = [];
let losProfileMeta = null;
let losPointMarkers = [];
let losSelectedPointIndex = null;
let losPointHeights = [];
const loadLosPointHeights = () => {
try {
const raw = localStorage.getItem('meshmapLosPointHeights');
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.map((value) => {
const n = Number(value);
return Number.isFinite(n) ? n : 0;
});
} catch (_err) {
return [];
}
};
losPointHeights = loadLosPointHeights();
const persistLosPointHeights = () => {
try {
localStorage.setItem('meshmapLosPointHeights', JSON.stringify(losPointHeights));
} catch (_err) {
// ignore storage failures
}
};
const getLosPointHeight = (index) => {
if (!Number.isInteger(index) || index < 0) return 0;
const value = Number(losPointHeights[index]);
return Number.isFinite(value) ? value : 0;
};
const setLosPointHeight = (index, value) => {
if (!Number.isInteger(index) || index < 0) return;
const next = Number(value);
losPointHeights[index] = Number.isFinite(next) ? next : 0;
persistLosPointHeights();
};
const syncLosHeightInputs = () => {
const startIdx = Number.isInteger(losActiveSegmentIndex) ? losActiveSegmentIndex : null;
const endIdx = Number.isInteger(startIdx) ? startIdx + 1 : null;
if (losHeightAInput) {
losHeightAInput.value = String(startIdx != null ? getLosPointHeight(startIdx) : 0);
losHeightAInput.disabled = startIdx == null;
}
if (losHeightBInput) {
losHeightBInput.value = String(endIdx != null ? getLosPointHeight(endIdx) : 0);
losHeightBInput.disabled = endIdx == null;
}
};
syncLosHeightInputs();
const deviceData = new Map();
const searchInput = document.getElementById('node-search');
const searchResults = document.getElementById('node-search-results');
const nodeSizeInput = document.getElementById('node-size');
const nodeSizeValue = document.getElementById('node-size-value');
let searchMatches = [];
const storedLabels = localStorage.getItem('meshmapShowLabels');
let showLabels = storedLabels === 'true';
if (storedLabels === null) {
localStorage.setItem('meshmapShowLabels', 'false');
}
const validUnits = new Set(['km', 'mi']);
let distanceUnits = (localStorage.getItem('meshmapDistanceUnits') || defaultDistanceUnits || 'km').toLowerCase();
if (!validUnits.has(distanceUnits)) {
distanceUnits = 'km';
localStorage.setItem('meshmapDistanceUnits', distanceUnits);
}
if (validUnits.has(queryUnits)) {
distanceUnits = queryUnits;
localStorage.setItem('meshmapDistanceUnits', distanceUnits);
}
const NODE_RADIUS_MIN = 4;
const NODE_RADIUS_MAX = 14;
const envNodeRadius = Number(config.nodeRadius);
const defaultNodeRadius = Number.isFinite(envNodeRadius) ? envNodeRadius : 8;
let nodeMarkerRadius = defaultNodeRadius;
const storedRadius = parseNumberParam(localStorage.getItem('meshmapNodeRadius'));
if (Number.isFinite(storedRadius)) {
nodeMarkerRadius = storedRadius;
}
nodeMarkerRadius = clampNumber(nodeMarkerRadius, NODE_RADIUS_MIN, NODE_RADIUS_MAX);
if (!Number.isFinite(storedRadius)) {
localStorage.setItem('meshmapNodeRadius', String(nodeMarkerRadius));
}
const historyLabel = document.getElementById('history-window-label');
const historyFilter = document.getElementById('history-filter');
const historyFilterLabel = document.getElementById('history-filter-label');
const historyLinkSizeInput = document.getElementById('history-link-size');
const historyLinkSizeValue = document.getElementById('history-link-size-value');
let historyWindowSeconds = null;
const historyToolVersion = '1';
localStorage.setItem('meshmapHistoryToolVersion', historyToolVersion);
let historyVisible = false;
let historyPanelHidden = false;
let weatherPanelHidden = false;
let peersActive = false;
let peersSelectedId = null;
let peersData = null;
let peersRequestToken = 0;
let historyFilterMode = Number(localStorage.getItem('meshmapHistoryFilter') || '0');
if (queryHistoryFilter != null) {
historyFilterMode = queryHistoryFilter;
localStorage.setItem('meshmapHistoryFilter', String(historyFilterMode));
}
if (![0, 1, 2, 3, 4].includes(historyFilterMode)) {
historyFilterMode = 0;
localStorage.setItem('meshmapHistoryFilter', '0');
}
if (historyFilter) {
historyFilter.value = String(historyFilterMode);
}
const HISTORY_LINK_MIN = 0.1;
const HISTORY_LINK_MID = 1;
const HISTORY_LINK_MAX = 2;
const envHistoryLinkScale = Number(config.historyLinkScale);
let historyLinkScale = Number.isFinite(envHistoryLinkScale) ? envHistoryLinkScale : 1;
const storedHistoryLinkScale = parseNumberParam(localStorage.getItem('meshmapHistoryLinkScale'));
if (Number.isFinite(storedHistoryLinkScale)) {
historyLinkScale = storedHistoryLinkScale;
}
historyLinkScale = clampNumber(historyLinkScale, HISTORY_LINK_MIN, HISTORY_LINK_MAX);
if (!Number.isFinite(storedHistoryLinkScale)) {
localStorage.setItem('meshmapHistoryLinkScale', String(historyLinkScale));
}
const sliderToHistoryScale = (value) => {
const t = clampNumber(Number(value), 0, 100);
if (t <= 50) {
return HISTORY_LINK_MIN + (t / 50) * (HISTORY_LINK_MID - HISTORY_LINK_MIN);
}
return HISTORY_LINK_MID + ((t - 50) / 50) * (HISTORY_LINK_MAX - HISTORY_LINK_MID);
};
const historyScaleToSlider = (scale) => {
const v = clampNumber(scale, HISTORY_LINK_MIN, HISTORY_LINK_MAX);
if (v <= HISTORY_LINK_MID) {
return ((v - HISTORY_LINK_MIN) / (HISTORY_LINK_MID - HISTORY_LINK_MIN)) * 50;
}
return 50 + ((v - HISTORY_LINK_MID) / (HISTORY_LINK_MAX - HISTORY_LINK_MID)) * 50;
};
const updateHistoryLinkSizeUI = () => {
if (historyLinkSizeInput) {
historyLinkSizeInput.value = String(Math.round(historyScaleToSlider(historyLinkScale)));
}
if (historyLinkSizeValue) historyLinkSizeValue.textContent = `${historyLinkScale.toFixed(1)}x`;
};
updateHistoryLinkSizeUI();
const storedHeat = localStorage.getItem('meshmapShowHeat');
let heatVisible = storedHeat !== 'false';
if (storedHeat === null) {
localStorage.setItem('meshmapShowHeat', 'true');
}
const mqttWindowLabel = document.getElementById('mqtt-online-label');
if (mqttWindowLabel) {
const mqttWindowSeconds = Math.max(
mqttOnlineSeconds,
mqttOnlineStatusTtlSeconds,
mqttOnlineInternalTtlSeconds
);
mqttWindowLabel.textContent = `MQTT online (last ${formatOnlineWindow(mqttWindowSeconds)})`;
}
const propagationLayer = L.layerGroup().addTo(map);
let propagationActive = false;
let propagationOrigins = [];
let propagationOriginMarkers = new Map();
let propagationOriginSeq = 0;
let propagationRaster = null;
let propagationRasterCanvas = null;
let propagationRasterMeta = null;
let propagationBaseRange = null;
let propagationNeedsRender = false;
let propagationRenderInFlight = false;
let propagationComputeToken = 0;
let propagationWorker = null;
let propagationLastConfig = null;
let propagationGpu = null;
let propagationGpuInitPromise = null;
const LOS_PANEL_COLLAPSED_KEY = 'meshmapLosPanelCollapsed';
const PROP_PANEL_COLLAPSED_KEY = 'meshmapPropPanelCollapsed';
const HISTORY_PANEL_COLLAPSED_KEY = 'meshmapHistoryPanelCollapsed';
const PEERS_PANEL_COLLAPSED_KEY = 'meshmapPeersPanelCollapsed';
const ROUTE_DETAILS_PANEL_COLLAPSED_KEY = 'meshmapRouteDetailsPanelCollapsed';
let losPanelCollapsed = parseBoolParam(localStorage.getItem(LOS_PANEL_COLLAPSED_KEY)) === true;
let propPanelCollapsed = parseBoolParam(localStorage.getItem(PROP_PANEL_COLLAPSED_KEY)) === true;
let historyPanelCollapsed =
parseBoolParam(localStorage.getItem(HISTORY_PANEL_COLLAPSED_KEY)) === true;
let peersPanelCollapsed =
parseBoolParam(localStorage.getItem(PEERS_PANEL_COLLAPSED_KEY)) === true;
let routeDetailsPanelCollapsed =
parseBoolParam(localStorage.getItem(ROUTE_DETAILS_PANEL_COLLAPSED_KEY)) === true;
const PROP_DEFAULTS = {
freqMHz: 910.525,
bwHz: 62500,
sf: 7,
cr: 8,
snrMinDb: -7.5,
noiseFigureDb: 6,
fadeMarginDb: 10,
fresnelFactor: 0.2,
txAntennaGainDb: 3,
clearanceRatio: 0.6,
clearanceLossDb: 12,
earthRadiusM: 6371000 * (4 / 3)
};
const PROP_TERRARIUM_URL = 'https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png';
const PROP_MODELS = {
free: { label: 'Best-case (free-space)', n: 2.0, clutterLossDb: 0 },
suburban: { label: 'Suburban', n: 2.2, clutterLossDb: 6 },
urban: { label: 'Urban', n: 2.3, clutterLossDb: 10 },
indoor: { label: 'Indoor/obstructed', n: 2.7, clutterLossDb: 18 }
};
function resolveRole(d) {
const role = (d.role || '').toLowerCase();
if (role.includes('repeater')) return 'repeater';
if (role.includes('companion')) return 'companion';
if (role.includes('room')) return 'room';
return 'unknown';
}
function meshCoreContactTypeForDevice(d) {
const role = String(d?.role || '').toLowerCase();
if (role.includes('repeater')) return 2;
if (role.includes('room')) return 3;
if (role.includes('sensor')) return 4;
return 1;
}
function markerStyleForRole(role) {
if (role === 'repeater') {
return { color: '#1d4ed8', fillColor: '#2b8cff', fillOpacity: 0.95, radius: nodeMarkerRadius, weight: 2 };
}
if (role === 'companion') {
return { color: '#6b21a8', fillColor: '#a855f7', fillOpacity: 0.95, radius: nodeMarkerRadius, weight: 2 };
}
if (role === 'room') {
return { color: '#b45309', fillColor: '#f59e0b', fillOpacity: 0.95, radius: nodeMarkerRadius, weight: 2 };
}
return { color: '#4b5563', fillColor: '#d1d5db', fillOpacity: 0.95, radius: nodeMarkerRadius, weight: 2 };
}
function markerStyleForDevice(d) {
const role = resolveRole(d);
const base = markerStyleForRole(role);
if (isMqttOnline(d)) {
return { ...base, color: '#22c55e', weight: 3 };
}
return base;
}
function applyMqttPresenceSummary(summary) {
if (!summary || typeof summary !== 'object') return;
if (summary.connected_total != null) {
const n = Number(summary.connected_total);
if (Number.isFinite(n) && n >= 0) {
mqttConnectedTotal = Math.floor(n);
mqttPresenceKnown = true;
}
}
}
function setStats() {
const onlineOnMap = Array.from(deviceData.values()).filter(isMqttOnline).length;
const onlineTotal = mqttPresenceKnown ? mqttConnectedTotal : onlineOnMap;
document.getElementById('stats').textContent = `${markers.size} active devices • ${onlineTotal} MQTT online • ${routeLines.size} routes • ${historyLines.size} history`;
}
let statsFramePending = false;
let deferStats = false;
function scheduleStatsUpdate() {
if (statsFramePending) return;
statsFramePending = true;
window.requestAnimationFrame(() => {
statsFramePending = false;
setStats();
});
}
function refreshStats() {
if (deferStats) {
scheduleStatsUpdate();
return;
}
setStats();
}
function formatOnlineWindow(seconds) {
if (!seconds || seconds <= 0) return '0 min';
if (seconds >= 3600) {
const hours = Math.round(seconds / 3600);
return `${hours} hr`;
}
const minutes = Math.max(1, Math.round(seconds / 60));
return `${minutes} min`;
}
// Geohash decoder (simple implementation)
function geohashDecode(geohash) {
const BASE32 = '0123456789bcdefghjkmnpqrstuvwxyz';
const BASE32_DICT = {};
for (let i = 0; i < BASE32.length; i++) {
BASE32_DICT[BASE32[i]] = i;
}
let even = true;
let lat = [-90.0, 90.0];
let lon = [-180.0, 180.0];
let lat_err = 90.0;
let lon_err = 180.0;
for (let i = 0; i < geohash.length; i++) {
const c = geohash[i];
const cd = BASE32_DICT[c];
for (let j = 0; j < 5; j++) {
if (even) {
lon_err /= 2;
if ((cd & (16 >> j)) > 0) {
lon[0] = (lon[0] + lon[1]) / 2;
} else {
lon[1] = (lon[0] + lon[1]) / 2;
}
} else {
lat_err /= 2;
if ((cd & (16 >> j)) > 0) {
lat[0] = (lat[0] + lat[1]) / 2;
} else {
lat[1] = (lat[0] + lat[1]) / 2;
}
}
even = !even;
}
}
return {
latitude: (lat[0] + lat[1]) / 2,
longitude: (lon[0] + lon[1]) / 2,
error: { latitude: lat_err, longitude: lon_err }
};
}
function geohashDecodeBbox(geohash) {
const decoded = geohashDecode(geohash);
const latErr = decoded.error.latitude;
const lonErr = decoded.error.longitude;
return [
decoded.latitude - latErr,
decoded.longitude - lonErr,
decoded.latitude + latErr,
decoded.longitude + lonErr
];
}
function successRateToColor(rate) {
const clampedRate = Math.max(0, Math.min(1, rate));
let red, green, blue;
if (clampedRate >= 0.8) {
const t = (clampedRate - 0.8) / 0.2;
red = Math.round(0 + (50 - 0) * t);
green = Math.round(100 + (150 - 100) * t);
blue = Math.round(0 + (50 - 0) * t);
} else if (clampedRate >= 0.6) {
const t = (clampedRate - 0.6) / 0.2;
red = Math.round(50 + (255 - 50) * t);
green = Math.round(150 + (165 - 150) * t);
blue = Math.round(50 - 50 * t);
} else if (clampedRate >= 0.4) {
const t = (clampedRate - 0.4) / 0.2;
red = 255;
green = Math.round(165 + (100 - 165) * t);
blue = 0;
} else if (clampedRate >= 0.2) {
const t = (clampedRate - 0.2) / 0.2;
red = 255;
green = Math.round(100 - 100 * t);
blue = 0;
} else {
red = 255;
green = 0;
blue = 0;
}
const toHex = (n) => {
const hex = n.toString(16);
return hex.length === 1 ? '0' + hex : hex;
};
return `#${toHex(red)}${toHex(green)}${toHex(blue)}`;
}
async function fetchCoverageData() {
try {
const response = await fetch(withToken('/coverage'));
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
const data = await response.json();
return {
data,
provider: String(response.headers.get('x-coverage-provider') || '').trim().toLowerCase(),
region: String(response.headers.get('x-coverage-region') || '').trim().toUpperCase()
};
} catch (err) {
const errorMsg = err && err.message ? err.message : String(err);
reportError(`Failed to fetch coverage data: ${errorMsg}`);
return null;
}
}
function buildCoverageAttributionHtml() {
if (coverageProvider !== 'meshmapper' || !coverageRegion) return '';
if (!/^[A-Z0-9-]+$/.test(coverageRegion)) return '';
const regionHost = coverageRegion.toLowerCase();
return `<a href="https://${regionHost}.meshmapper.net" target="_blank" rel="noopener noreferrer">MeshMapper</a>`;
}
function updateCoverageAttribution() {
const next = (coverageVisible && coverageData) ? buildCoverageAttributionHtml() : '';
if (coverageAttributionHtml && map && map.attributionControl) {
map.attributionControl.removeAttribution(coverageAttributionHtml);
coverageAttributionHtml = '';
}
if (next && map && map.attributionControl) {
map.attributionControl.addAttribution(next);
coverageAttributionHtml = next;
}
}
function updateCoverageLegend() {
if (!coverageLegendGroup) return;
const active = coverageVisible && coverageProvider === 'meshmapper' && Array.isArray(coverageData) && coverageData.length > 0;
coverageLegendGroup.classList.toggle('active', active);
}
function getRenderBounds() {
if (!map || !map.getBounds) return null;
try {
return map.getBounds().pad(0.2);
} catch (_err) {
return null;
}
}
function boundsIntersectsViewport(south, west, north, east) {
const viewport = getRenderBounds();
if (!viewport) return true;
const tileBounds = L.latLngBounds([[south, west], [north, east]]);
return viewport.intersects(tileBounds);
}
function latLngInViewport(lat, lon) {
const viewport = getRenderBounds();
if (!viewport) return true;
return viewport.contains([lat, lon]);
}
function syncLayerMembership(layer, item, shouldAttach) {
if (!layer || !item) return;
const attached = item.__attached === true;
if (shouldAttach && !attached) {
layer.addLayer(item);
item.__attached = true;
return;
}
if (!shouldAttach && attached) {
layer.removeLayer(item);
item.__attached = false;
}
}
function coverageTimeLabel(ts) {
const raw = typeof ts === 'string' ? Number.parseInt(ts, 10) : ts;
if (!Number.isFinite(raw) || raw <= 0) return '';
const ms = raw < 946684800000 ? raw * 1000 : raw;
const date = new Date(ms);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleString();
}
function meshMapperCoveragePriority(type) {
switch (String(type || '').trim().toUpperCase()) {
case 'BIDIR':
return 6;
case 'DISC':
case 'TRACE':
case 'DISC/TRACE':
return 5;
case 'TX':
return 4;
case 'RX':
return 3;
case 'DEAD':
return 2;
case 'DROP':
return 1;
default:
return 0;
}
}
function expandMeshMapperCoverageSquares(data) {
const merged = new Map();
for (const square of data) {
const bounds = square?.bounds;
if (!bounds) continue;
const south = Number(bounds.south);
const west = Number(bounds.west);
const north = Number(bounds.north);
const east = Number(bounds.east);
if (![south, west, north, east].every(Number.isFinite)) continue;
const latStep = north - south;
const lonStep = east - west;
if (!(latStep > 0) || !(lonStep > 0)) continue;
const priority = meshMapperCoveragePriority(square.coverage_type);
const timestamp = Number(square.timestamp) || 0;
for (let latOffset = -1; latOffset <= 1; latOffset += 1) {
for (let lonOffset = -1; lonOffset <= 1; lonOffset += 1) {
const nextSouth = south + (latStep * latOffset);
const nextWest = west + (lonStep * lonOffset);
const nextNorth = nextSouth + latStep;
const nextEast = nextWest + lonStep;
const key = `${nextSouth.toFixed(7)}:${nextWest.toFixed(7)}`;
const current = merged.get(key);
const shouldReplace = !current
|| priority > current.__priority
|| (priority === current.__priority && timestamp > current.__timestamp);
if (!shouldReplace) continue;
merged.set(key, {
...square,
bounds: {
south: nextSouth,
west: nextWest,
north: nextNorth,
east: nextEast
},
__priority: priority,
__timestamp: timestamp,
__source_grid_id: square.grid_id || null
});
}
}
}
return Array.from(merged.values());
}
function buildMeshMapperCoverageRect(square) {
const bounds = square?.bounds;
if (!bounds) return null;
const south = Number(bounds.south);
const west = Number(bounds.west);
const north = Number(bounds.north);
const east = Number(bounds.east);
if (![south, west, north, east].every(Number.isFinite)) return null;
const fillColor = square.fill_color || '#1e7e34';
const rect = L.rectangle([[south, west], [north, east]], {
renderer: vectorRenderer,
stroke: true,
color: square.border_color || fillColor,
weight: 0.4,
opacity: 0.22,
fillOpacity: 0.5,
fillColor
});
const details = [];
if (square.coverage_type) details.push(`Type: ${square.coverage_type}`);
if (square.snr !== null && square.snr !== undefined) {
details.push(`SNR: ${square.snr} dB`);
}
const when = coverageTimeLabel(square.timestamp);
if (when) details.push(`Updated: ${when}`);
if (square.__source_grid_id) details.push(`Source grid: ${square.__source_grid_id}`);
rect.bindPopup(details.join('<br/>'), { maxWidth: 320 });
rect.__coverageBounds = { south, west, north, east };
rect.__attached = false;
return rect;
}
function renderMeshMapperCoverage(data) {
coverageLayer.clearLayers();
meshMapperCoverageSource = data;
meshMapperCoverageExpanded = expandMeshMapperCoverageSquares(data);
meshMapperCoverageRects = [];
for (const square of meshMapperCoverageExpanded) {
const rect = buildMeshMapperCoverageRect(square);
if (rect) meshMapperCoverageRects.push(rect);
}
return syncMeshMapperCoverageViewport();
}
function syncMeshMapperCoverageViewport() {
let rendered = 0;
for (const rect of meshMapperCoverageRects) {
const bounds = rect?.__coverageBounds;
if (!bounds) continue;
const visible = boundsIntersectsViewport(
bounds.south,
bounds.west,
bounds.north,
bounds.east
);
syncLayerMembership(coverageLayer, rect, visible);
if (visible) rendered++;
}
return rendered;
}
function renderCoverage(data) {
coverageLayer.clearLayers();
meshMapperCoverageRects = [];
meshMapperCoverageSource = null;
meshMapperCoverageExpanded = [];
if (!data || !Array.isArray(data)) {
updateCoverageAttribution();
updateCoverageLegend();
return;
}
if (data.some(sample => sample && typeof sample === 'object' && sample.bounds)) {
renderMeshMapperCoverage(data);
updateCoverageAttribution();
updateCoverageLegend();
return;
}
// Aggregate samples by 6-char geohash prefix (coverage tile level)
const tileMap = new Map(); // 6-char prefix -> { heard: count, lost: count, samples: [...] }
for (const sample of data) {
const hash = sample.hash || sample.name || sample.id;
if (!hash) continue;
const tileHash = hash.substring(0, 6); // Use 6-char prefix for coverage tiles
if (!tileMap.has(tileHash)) {
tileMap.set(tileHash, { heard: 0, lost: 0, samples: [], latestTime: 0, snr: null, rssi: null, paths: new Set() });
}
const tile = tileMap.get(tileHash);
const observed = sample.observed !== undefined ? sample.observed : (sample.metadata?.observed !== undefined ? sample.metadata.observed : ((sample.path || sample.metadata?.path || []).length > 0));
if (observed) {
tile.heard++;
} else {
tile.lost++;
}
tile.samples.push(sample);
const time = sample.time || sample.metadata?.time || 0;
// Convert to number if it's a string, and handle milliseconds vs seconds
let timeValue = typeof time === 'string' ? parseInt(time, 10) : (typeof time === 'number' ? time : 0);
// If time looks like seconds (less than year 2000 in milliseconds), convert to milliseconds
if (timeValue > 0 && timeValue < 946684800000) {
timeValue = timeValue * 1000;
}
if (timeValue > tile.latestTime) {
tile.latestTime = timeValue;
tile.snr = sample.snr !== null && sample.snr !== undefined ? sample.snr : (sample.metadata?.snr !== null && sample.metadata?.snr !== undefined ? sample.metadata.snr : tile.snr);
tile.rssi = sample.rssi !== null && sample.rssi !== undefined ? sample.rssi : (sample.metadata?.rssi !== null && sample.metadata?.rssi !== undefined ? sample.metadata.rssi : tile.rssi);
}
const path = sample.path || sample.metadata?.path || [];
path.forEach(p => tile.paths.add(p));
}
let rendered = 0;
for (const [tileHash, tile] of tileMap.entries()) {
try {
const [minLat, minLon, maxLat, maxLon] = geohashDecodeBbox(tileHash);
if (!boundsIntersectsViewport(minLat, minLon, maxLat, maxLon)) continue;
const totalSamples = tile.heard + tile.lost;
if (totalSamples === 0) continue;
const heardRatio = totalSamples > 0 ? tile.heard / totalSamples : 0;
const color = successRateToColor(heardRatio);
const baseOpacity = 0.75 * Math.min(1, totalSamples / 10);
const opacity = heardRatio > 0 ? baseOpacity * heardRatio : Math.max(baseOpacity, 0.4);
const rect = L.rectangle([[minLat, minLon], [maxLat, maxLon]], {
renderer: vectorRenderer,
color: color,
weight: 1,
fillOpacity: Math.max(opacity, 0.2),
fillColor: color
});
let details = `Heard: ${tile.heard} Lost: ${tile.lost} (${(100 * heardRatio).toFixed(0)}%)`;
if (tile.paths.size > 0) {
const repeaters = Array.from(tile.paths).slice(0, 5).map(r => r.toUpperCase());
details += `<br/>Repeaters: ${repeaters.join(', ')}${tile.paths.size > 5 ? '...' : ''}`;
}
if (tile.snr !== null && tile.snr !== undefined) {
details += `<br/>SNR: ${tile.snr} dB`;
}
if (tile.rssi !== null && tile.rssi !== undefined) {
details += `<br/>RSSI: ${tile.rssi} dBm`;
}
rect.bindPopup(details, { maxWidth: 320 });
coverageLayer.addLayer(rect);
rendered++;
} catch (err) {
// Silently skip invalid tiles
}
}
updateCoverageAttribution();
updateCoverageLegend();
}
function setCoverageVisible(visible) {
coverageVisible = visible;
const btn = document.getElementById('coverage-toggle');
if (btn) {
btn.classList.toggle('active', visible);
btn.textContent = visible ? 'Hide coverage' : 'Coverage';
}
if (!nodesVisible) {
if (coverageLayer && map.hasLayer(coverageLayer)) {
map.removeLayer(coverageLayer);
}
updateCoverageAttribution();
updateCoverageLegend();
return;
}
if (visible) {
if (!map.hasLayer(coverageLayer)) {
coverageLayer.addTo(map);
}
if (!coverageData) {
fetchCoverageData().then(result => {
if (result && Array.isArray(result.data)) {
coverageData = result.data;
coverageProvider = result.provider || '';
coverageRegion = result.region || '';
if (result.data.length === 0) {
reportError('Coverage database appears to be empty. Add coverage data to your coverage map server.');
}
renderCoverage(result.data);
} else {
updateCoverageLegend();
reportError('Coverage API returned invalid data format');
}
});
} else {
renderCoverage(coverageData);
}
} else {
if (map.hasLayer(coverageLayer)) {
map.removeLayer(coverageLayer);
}
updateCoverageAttribution();
updateCoverageLegend();
}
}
function getRadarCountryRequestKey(lat, lon) {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return '';
return `${lat.toFixed(2)},${lon.toFixed(2)}`;
}
function getRadarCountryLookupCenter() {
const center = typeof map.getCenter === 'function' ? map.getCenter() : null;
const lat = center && Number.isFinite(center.lat) ? center.lat : mapStartLat;
const lon = center && Number.isFinite(center.lng) ? center.lng : mapStartLon;
return { lat, lon };
}
function getRadarBoundsKey(bounds) {
if (!Array.isArray(bounds) || bounds.length !== 2) return '';
const southWest = bounds[0];
const northEast = bounds[1];
if (!Array.isArray(southWest) || southWest.length !== 2) return '';
if (!Array.isArray(northEast) || northEast.length !== 2) return '';
const south = Number(southWest[0]);
const west = Number(southWest[1]);
const north = Number(northEast[0]);
const east = Number(northEast[1]);
if (!Number.isFinite(south) || !Number.isFinite(west)) return '';
if (!Number.isFinite(north) || !Number.isFinite(east)) return '';
if (south >= north || west >= east) return '';
return `${south.toFixed(4)},${west.toFixed(4)},${north.toFixed(4)},${east.toFixed(4)}`;
}
function parseRadarCountryBounds(payload) {
if (!payload || typeof payload !== 'object') return null;
let south = NaN;
let north = NaN;
let west = NaN;
let east = NaN;
const rawBounds = payload.bounds && typeof payload.bounds === 'object' ? payload.bounds : null;
if (rawBounds) {
south = Number(rawBounds.south);
north = Number(rawBounds.north);
west = Number(rawBounds.west);
east = Number(rawBounds.east);
} else {
const bbox = Array.isArray(payload.boundingbox) ? payload.boundingbox : [];
if (bbox.length < 4) return null;
south = Number(bbox[0]);
north = Number(bbox[1]);
west = Number(bbox[2]);
east = Number(bbox[3]);
}
const bounds = [[south, west], [north, east]];
const boundsKey = getRadarBoundsKey(bounds);
if (!boundsKey) return null;
const address = payload.address && typeof payload.address === 'object' ? payload.address : {};
const countryCode = typeof address.country_code === 'string'
? address.country_code.trim().toLowerCase()
: '';
return { bounds, boundsKey, countryCode };
}
function loadStoredRadarCountryBounds() {
if (!weatherRadarEnabled || !weatherRadarCountryBoundsEnabled) return;
let raw = '';
try {
raw = localStorage.getItem(WEATHER_RADAR_COUNTRY_CACHE_KEY) || '';
} catch (err) {
return;
}
if (!raw) return;
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return;
const center = getRadarCountryLookupCenter();
const expectedKey = getRadarCountryRequestKey(center.lat, center.lon);
const requestKey = typeof parsed.requestKey === 'string' ? parsed.requestKey : '';
if (expectedKey && requestKey && expectedKey !== requestKey) return;
const bounds = Array.isArray(parsed.bounds) ? parsed.bounds : null;
const boundsKey = getRadarBoundsKey(bounds);
if (!boundsKey) return;
radarCountryBounds = bounds;
radarCountryBoundsKey = boundsKey;
} catch (err) {
// Ignore invalid cache payloads
}
}
function buildRadarCountryLookupUrl(lat, lon) {
const baseUrl = weatherRadarCountryLookupUrl || '/weather/radar/country-bounds';
let lookup;
try {
lookup = new URL(baseUrl, window.location.origin);
} catch (err) {
return '';
}
lookup.searchParams.set('format', 'jsonv2');
lookup.searchParams.set('lat', String(lat));
lookup.searchParams.set('lon', String(lon));
lookup.searchParams.set('zoom', '3');
lookup.searchParams.set('addressdetails', '1');
if (lookup.origin === window.location.origin && prodMode && apiToken) {
lookup.searchParams.set('token', apiToken);
}
return lookup.toString();
}
async function ensureRadarCountryBounds(options = {}) {
const silent = options.silent === true;
if (!weatherRadarEnabled || !weatherRadarCountryBoundsEnabled) return null;
if (!weatherRadarCountryLookupUrl) return null;
if (radarCountryBoundsKey && radarCountryBounds) return radarCountryBounds;
if (radarCountryBoundsPromise) return radarCountryBoundsPromise;
loadStoredRadarCountryBounds();
if (radarCountryBoundsKey && radarCountryBounds) return radarCountryBounds;
const center = getRadarCountryLookupCenter();
const requestKey = getRadarCountryRequestKey(center.lat, center.lon);
if (!requestKey) return null;
const lookupUrl = buildRadarCountryLookupUrl(center.lat, center.lon);
if (!lookupUrl) return null;
radarCountryBoundsPromise = (async () => {
try {
const response = await fetch(lookupUrl, { cache: 'force-cache' });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
const parsed = parseRadarCountryBounds(payload);
if (!parsed) {
throw new Error('Country bounds missing in lookup response');
}
radarCountryBounds = parsed.bounds;
radarCountryBoundsKey = parsed.boundsKey;
try {
localStorage.setItem(WEATHER_RADAR_COUNTRY_CACHE_KEY, JSON.stringify({
requestKey,
countryCode: parsed.countryCode,
bounds: parsed.bounds
}));
} catch (err) {
// Ignore cache write issues
}
return radarCountryBounds;
} catch (err) {
if (!silent) {
const errorMsg = err && err.message ? err.message : String(err);
reportError(`Radar country lookup failed: ${errorMsg}`);
}
return null;
} finally {
radarCountryBoundsPromise = null;
}
})();
return radarCountryBoundsPromise;
}
async function fetchLatestRadarFrame() {
let lastError = null;
for (const metaUrl of RADAR_META_URLS) {
try {
const response = await fetch(metaUrl, { cache: 'no-store' });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const host = typeof data.host === 'string' && data.host.trim()
? data.host.replace(/\/+$/, '')
: 'https://tilecache.rainviewer.com';
const radar = data && typeof data === 'object' ? data.radar : null;
const pastFrames = radar && Array.isArray(radar.past) ? radar.past : [];
const nowcastFrames = radar && Array.isArray(radar.nowcast) ? radar.nowcast : [];
const frames = nowcastFrames.length > 0 ? nowcastFrames : pastFrames;
if (!frames.length) {
throw new Error('No radar frames available');
}
const latest = frames[frames.length - 1] || {};
const path = typeof latest.path === 'string' ? latest.path : '';
if (!path) {
throw new Error('Radar frame path missing');
}
return { host, path };
} catch (err) {
const message = err && err.message ? err.message : String(err);
lastError = `${metaUrl}: ${message}`;
}
}
throw new Error(lastError || 'Radar metadata unavailable');
}
function updateRadarLayer(host, path) {
if (!path) return;
const normalizedHost = host ? host.replace(/\/+$/, '') : 'https://tilecache.rainviewer.com';
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
const activeBounds = weatherRadarCountryBoundsEnabled && radarCountryBoundsKey ? radarCountryBounds : null;
const activeBoundsKey = weatherRadarCountryBoundsEnabled ? radarCountryBoundsKey : '';
if (
radarLayer &&
radarFrameHost === normalizedHost &&
radarFramePath === normalizedPath &&
radarLayerBoundsKey === activeBoundsKey
) {
return;
}
radarFrameHost = normalizedHost;
radarFramePath = normalizedPath;
if (radarLayer) {
radarLayerGroup.removeLayer(radarLayer);
radarLayer = null;
}
const tileUrl = `${normalizedHost}${normalizedPath}/256/{z}/{x}/{y}/4/1_1.png`;
const layerOptions = {
pane: 'radarPane',
opacity: 0.58,
maxNativeZoom: RADAR_MAX_NATIVE_ZOOM,
maxZoom: 18,
attribution: '&copy; RainViewer'
};
if (activeBounds) {
layerOptions.bounds = L.latLngBounds(activeBounds);
}
radarLayer = L.tileLayer(tileUrl, layerOptions);
radarLayerBoundsKey = activeBoundsKey;
radarLayerGroup.addLayer(radarLayer);
localStorage.setItem('meshmapRadarHost', radarFrameHost);
localStorage.setItem('meshmapRadarPath', radarFramePath);
if (!radarVisible) {
startRadarPreload();
}
}
function getWeatherWindSamplePoints() {
const center = map.getCenter();
const fallback = [{ lat: center.lat, lon: center.lng }];
const bounds = map.getBounds();
if (!bounds || !bounds.isValid() || weatherWindGridSize <= 1) return fallback;
const south = bounds.getSouth();
const north = bounds.getNorth();
const west = bounds.getWest();
const east = bounds.getEast();
if (
!Number.isFinite(south) || !Number.isFinite(north) ||
!Number.isFinite(west) || !Number.isFinite(east) ||
south >= north || west >= east
) {
return fallback;
}
const points = [];
for (let row = 0; row < weatherWindGridSize; row += 1) {
const yRatio = weatherWindGridSize === 1 ? 0.5 : (row + 0.5) / weatherWindGridSize;
const lat = south + (north - south) * yRatio;
for (let col = 0; col < weatherWindGridSize; col += 1) {
const xRatio = weatherWindGridSize === 1 ? 0.5 : (col + 0.5) / weatherWindGridSize;
const lon = west + (east - west) * xRatio;
points.push({ lat, lon });
}
}
return points.length ? points : fallback;
}
function weatherWindUnitLabel() {
return distanceUnits === 'mi' ? 'mph' : 'km/h';
}
function weatherWindIcon(speed, direction) {
const speedLabel = Number.isFinite(speed) ? Math.max(0, Math.round(speed)) : 0;
const safeDirection = Number.isFinite(direction)
? ((direction % 360) + 360) % 360
: 0;
return L.divIcon({
className: 'weather-wind-icon',
iconSize: [52, 24],
iconAnchor: [26, 12],
html:
`<span class="weather-wind-arrow" style="transform: rotate(${safeDirection}deg)">➤</span>` +
`<span class="weather-wind-speed">${speedLabel}${weatherWindUnitLabel()}</span>`
});
}
async function fetchWeatherWindSamples(points) {
if (!Array.isArray(points) || !points.length) return [];
if (!weatherWindApiUrl) {
throw new Error('Wind API URL not configured');
}
let url;
try {
url = new URL(weatherWindApiUrl, window.location.origin);
} catch (err) {
throw new Error('Wind API URL is invalid');
}
const latCsv = points.map((point) => point.lat.toFixed(5)).join(',');
const lonCsv = points.map((point) => point.lon.toFixed(5)).join(',');
url.searchParams.set('latitude', latCsv);
url.searchParams.set('longitude', lonCsv);
url.searchParams.set('current', 'wind_speed_10m,wind_direction_10m');
url.searchParams.set('wind_speed_unit', distanceUnits === 'mi' ? 'mph' : 'kmh');
if (url.origin === window.location.origin && prodMode && apiToken) {
url.searchParams.set('token', apiToken);
}
const response = await fetch(url.toString(), { cache: 'no-store' });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
const batch = Array.isArray(payload) ? payload : [payload];
const samples = [];
for (let i = 0; i < points.length; i += 1) {
const source = batch[i] || batch[0] || {};
const current = source && typeof source === 'object' ? source.current : null;
const speed = Number(current && current.wind_speed_10m);
const direction = Number(current && current.wind_direction_10m);
if (!Number.isFinite(speed) || !Number.isFinite(direction)) {
continue;
}
samples.push({
lat: points[i].lat,
lon: points[i].lon,
speed,
direction
});
}
if (!samples.length) {
throw new Error('Wind response missing fields');
}
return samples;
}
async function refreshWeatherWindLayer(options = {}) {
const silent = options.silent === true;
const background = options.background === true;
if (!weatherWindEnabled || !weatherWindLayerEnabled || !weatherWindApiUrl) return;
const manageUi = !(background && (!radarVisible || !weatherWindLayerEnabled));
const seq = ++weatherWindRequestSeq;
if (manageUi) {
weatherWindLoading = true;
updateRadarButtonState();
}
try {
const points = getWeatherWindSamplePoints();
const samples = await fetchWeatherWindSamples(points);
if (seq !== weatherWindRequestSeq) return;
weatherWindLayer.clearLayers();
for (const sample of samples) {
const marker = L.marker([sample.lat, sample.lon], {
icon: weatherWindIcon(sample.speed, sample.direction),
pane: 'weatherWindPane',
interactive: false,
keyboard: false
});
weatherWindLayer.addLayer(marker);
}
if (radarVisible && weatherWindLayerEnabled && nodesVisible && !map.hasLayer(weatherWindLayer)) {
weatherWindLayer.addTo(map);
}
} catch (err) {
if (!silent) {
const errorMsg = err && err.message ? err.message : String(err);
reportError(`Failed to fetch weather wind: ${errorMsg}`);
}
} finally {
if (manageUi && seq === weatherWindRequestSeq) {
weatherWindLoading = false;
updateRadarButtonState();
}
}
}
function stopWeatherWindRefresh() {
if (weatherWindRefreshTimer) {
window.clearInterval(weatherWindRefreshTimer);
weatherWindRefreshTimer = null;
}
if (weatherWindMoveTimer) {
window.clearTimeout(weatherWindMoveTimer);
weatherWindMoveTimer = null;
}
}
function startWeatherWindRefresh() {
if (!weatherWindEnabled || !weatherWindLayerEnabled) return;
stopWeatherWindRefresh();
weatherWindRefreshTimer = window.setInterval(() => {
if (!radarVisible || !weatherWindLayerEnabled) return;
refreshWeatherWindLayer({ silent: true, background: true });
}, weatherWindRefreshMs);
}
function scheduleWeatherWindRefresh() {
if (!weatherWindEnabled || !weatherWindLayerEnabled || !radarVisible) return;
if (weatherWindMoveTimer) {
window.clearTimeout(weatherWindMoveTimer);
}
weatherWindMoveTimer = window.setTimeout(() => {
refreshWeatherWindLayer({ silent: true, background: true });
}, 450);
}
function updateWeatherLayerButtons() {
if (weatherRadarLayerToggle) {
const available = weatherRadarEnabled;
weatherRadarLayerToggle.disabled = !available;
weatherRadarLayerToggle.classList.toggle('active', available && weatherRadarLayerEnabled);
if (!available) {
weatherRadarLayerToggle.textContent = 'Radar: unavailable';
} else {
weatherRadarLayerToggle.textContent = weatherRadarLayerEnabled ? 'Radar: on' : 'Radar: off';
}
}
if (weatherWindLayerToggle) {
const available = weatherWindEnabled;
weatherWindLayerToggle.disabled = !available;
weatherWindLayerToggle.classList.toggle('active', available && weatherWindLayerEnabled);
if (!available) {
weatherWindLayerToggle.textContent = 'Wind: unavailable';
} else {
weatherWindLayerToggle.textContent = weatherWindLayerEnabled ? 'Wind: on' : 'Wind: off';
}
}
}
function updateWeatherPanelVisibility() {
if (!weatherPanel) return;
const shouldShow = radarVisible && !weatherPanelHidden;
weatherPanel.classList.toggle('active', shouldShow);
if (shouldShow) {
weatherPanel.removeAttribute('hidden');
weatherPanel.style.display = 'block';
} else {
weatherPanel.setAttribute('hidden', 'hidden');
weatherPanel.style.display = 'none';
}
}
function setWeatherPanelHidden(hidden) {
weatherPanelHidden = Boolean(hidden);
updateWeatherPanelVisibility();
layoutSidePanels();
}
function updateRadarButtonState() {
const btn = document.getElementById('weather-toggle');
if (!btn) return;
if (!weatherAvailable) {
btn.setAttribute('hidden', 'hidden');
return;
}
btn.classList.toggle('active', radarVisible);
if (radarVisible && (radarLoading || weatherWindLoading)) {
btn.textContent = 'Weather: loading...';
} else {
btn.textContent = 'Weather';
}
}
function stopRadarPreload(keepLayer = false) {
radarPreloading = false;
if (radarPreloadTimeout) {
window.clearTimeout(radarPreloadTimeout);
radarPreloadTimeout = null;
}
if (radarLayer) {
radarLayer.setOpacity(0.58);
}
if (!keepLayer && (!radarVisible || !weatherRadarLayerEnabled) && map.hasLayer(radarLayerGroup)) {
map.removeLayer(radarLayerGroup);
}
}
function startRadarPreload() {
if (!radarLayer || radarVisible || radarPreloading) return;
radarPreloading = true;
radarLayer.setOpacity(0.01);
if (!map.hasLayer(radarLayerGroup)) {
radarLayerGroup.addTo(map);
}
const finish = () => stopRadarPreload(false);
radarLayer.once('load', finish);
radarPreloadTimeout = window.setTimeout(finish, 3000);
}
async function refreshRadarLayer(options = {}) {
const silent = options.silent === true;
const background = options.background === true;
const manageUi = !(background && (!radarVisible || !weatherRadarLayerEnabled));
const seq = ++radarRequestSeq;
if (manageUi) {
radarLoading = true;
updateRadarButtonState();
}
try {
const boundsPromise = weatherRadarCountryBoundsEnabled
? ensureRadarCountryBounds({ silent: true })
: null;
const frame = await fetchLatestRadarFrame();
if (boundsPromise) {
await boundsPromise;
}
if (seq !== radarRequestSeq) return;
updateRadarLayer(frame.host, frame.path);
if (radarVisible && weatherRadarLayerEnabled && nodesVisible && !map.hasLayer(radarLayerGroup)) {
radarLayerGroup.addTo(map);
}
} catch (err) {
if (!silent) {
const errorMsg = err && err.message ? err.message : String(err);
reportError(`Failed to fetch weather radar: ${errorMsg}`);
}
} finally {
if (manageUi && seq === radarRequestSeq) {
radarLoading = false;
updateRadarButtonState();
}
}
}
function stopRadarRefresh() {
if (radarRefreshTimer) {
window.clearInterval(radarRefreshTimer);
radarRefreshTimer = null;
}
}
function startRadarRefresh() {
stopRadarRefresh();
radarRefreshTimer = window.setInterval(() => {
if (!radarVisible || !weatherRadarLayerEnabled) return;
refreshRadarLayer();
}, RADAR_REFRESH_MS);
}
function setWeatherRadarLayerEnabled(enabled) {
if (!weatherRadarEnabled) {
weatherRadarLayerEnabled = false;
updateWeatherLayerButtons();
return;
}
weatherRadarLayerEnabled = Boolean(enabled);
try {
localStorage.setItem(
WEATHER_RADAR_LAYER_STORAGE_KEY,
weatherRadarLayerEnabled ? 'true' : 'false'
);
} catch (_err) {}
updateWeatherLayerButtons();
if (!radarVisible || !nodesVisible || !weatherRadarLayerEnabled) {
if (map.hasLayer(radarLayerGroup)) {
map.removeLayer(radarLayerGroup);
}
stopRadarPreload(false);
stopRadarRefresh();
return;
}
stopRadarPreload(true);
if (!map.hasLayer(radarLayerGroup)) {
radarLayerGroup.addTo(map);
}
if (radarLayer) {
radarLayer.setOpacity(0.58);
}
if (!radarLayer) {
refreshRadarLayer({ silent: true });
}
startRadarRefresh();
}
function setWeatherWindLayerEnabled(enabled) {
if (!weatherWindEnabled) {
weatherWindLayerEnabled = false;
updateWeatherLayerButtons();
return;
}
weatherWindLayerEnabled = Boolean(enabled);
try {
localStorage.setItem(
WEATHER_WIND_LAYER_STORAGE_KEY,
weatherWindLayerEnabled ? 'true' : 'false'
);
} catch (_err) {}
updateWeatherLayerButtons();
if (!radarVisible || !nodesVisible || !weatherWindLayerEnabled) {
if (map.hasLayer(weatherWindLayer)) {
map.removeLayer(weatherWindLayer);
}
stopWeatherWindRefresh();
return;
}
if (!map.hasLayer(weatherWindLayer)) {
weatherWindLayer.addTo(map);
}
refreshWeatherWindLayer({ silent: true, background: true });
startWeatherWindRefresh();
}
function setRadarVisible(visible) {
radarVisible = visible;
if (visible) {
weatherPanelHidden = false;
}
updateWeatherPanelVisibility();
updateRadarButtonState();
updateWeatherLayerButtons();
if (!nodesVisible) {
if (map.hasLayer(radarLayerGroup)) {
map.removeLayer(radarLayerGroup);
}
if (map.hasLayer(weatherWindLayer)) {
map.removeLayer(weatherWindLayer);
}
stopRadarPreload(false);
stopRadarRefresh();
stopWeatherWindRefresh();
layoutSidePanels();
return;
}
if (visible) {
if (weatherRadarLayerEnabled) {
stopRadarPreload(true);
if (!map.hasLayer(radarLayerGroup)) {
radarLayerGroup.addTo(map);
}
if (radarLayer) {
radarLayer.setOpacity(0.58);
}
if (!radarLayer) {
refreshRadarLayer();
}
startRadarRefresh();
} else {
if (map.hasLayer(radarLayerGroup)) {
map.removeLayer(radarLayerGroup);
}
stopRadarPreload(false);
stopRadarRefresh();
}
if (weatherWindEnabled && weatherWindLayerEnabled) {
if (!map.hasLayer(weatherWindLayer)) {
weatherWindLayer.addTo(map);
}
refreshWeatherWindLayer({ silent: true, background: true });
startWeatherWindRefresh();
} else {
if (map.hasLayer(weatherWindLayer)) {
map.removeLayer(weatherWindLayer);
}
stopWeatherWindRefresh();
}
} else {
if (map.hasLayer(radarLayerGroup)) {
map.removeLayer(radarLayerGroup);
}
if (map.hasLayer(weatherWindLayer)) {
map.removeLayer(weatherWindLayer);
}
stopRadarPreload(false);
stopRadarRefresh();
stopWeatherWindRefresh();
}
layoutSidePanels();
}
function updateNodeSizeUi() {
if (nodeSizeInput) {
nodeSizeInput.value = String(nodeMarkerRadius);
}
if (nodeSizeValue) {
nodeSizeValue.textContent = `${nodeMarkerRadius}px`;
}
}
function setNodeMarkerRadius(value, persist = true) {
const next = clampNumber(Number(value), NODE_RADIUS_MIN, NODE_RADIUS_MAX);
if (!Number.isFinite(next)) return;
nodeMarkerRadius = next;
if (persist) {
localStorage.setItem('meshmapNodeRadius', String(nodeMarkerRadius));
}
updateNodeSizeUi();
refreshOnlineMarkers();
}
function setNodesVisible(visible) {
nodesVisible = visible;
const btn = document.getElementById('nodes-toggle');
if (btn) {
btn.classList.toggle('active', !visible);
btn.textContent = visible ? 'Hide nodes' : 'Show nodes';
}
if (visible) {
if (!map.hasLayer(markerLayer)) {
markerLayer.addTo(map);
}
if (!map.hasLayer(trailLayer)) {
trailLayer.addTo(map);
}
if (!map.hasLayer(routeLayer)) {
routeLayer.addTo(map);
}
if (arcadeModeEnabled && !map.hasLayer(arcadeLayer)) {
arcadeLayer.addTo(map);
}
if (historyVisible && !map.hasLayer(historyLayer)) {
historyLayer.addTo(map);
renderHistoryFromCache();
}
if (heatVisible && heatLayer && !map.hasLayer(heatLayer)) {
heatLayer.addTo(map);
}
if (coverageVisible && !map.hasLayer(coverageLayer)) {
coverageLayer.addTo(map);
}
if (radarVisible && weatherRadarLayerEnabled && !map.hasLayer(radarLayerGroup)) {
radarLayerGroup.addTo(map);
}
if (radarVisible && weatherWindEnabled && weatherWindLayerEnabled && !map.hasLayer(weatherWindLayer)) {
weatherWindLayer.addTo(map);
}
if (radarVisible && weatherWindEnabled && weatherWindLayerEnabled) {
startWeatherWindRefresh();
}
if (radarVisible && weatherRadarLayerEnabled) {
startRadarRefresh();
}
if (peersActive && !map.hasLayer(peerLayer)) {
peerLayer.addTo(map);
}
if (peersActive && peersData) {
renderPeerLines(
{ lat: peersData.lat, lon: peersData.lon },
peersData.incoming || [],
peersData.outgoing || []
);
}
refreshViewportLayers();
if (arcadeModeEnabled) {
routeLines.forEach((entry, routeId) => {
if (entry && Array.isArray(entry.routePoints)) {
syncArcadeForRoute(routeId, entry.routePoints);
}
});
startArcadeLoop();
}
} else if (map.hasLayer(markerLayer)) {
map.removeLayer(markerLayer);
if (map.hasLayer(trailLayer)) {
map.removeLayer(trailLayer);
}
if (map.hasLayer(routeLayer)) {
map.removeLayer(routeLayer);
}
if (map.hasLayer(arcadeLayer)) {
map.removeLayer(arcadeLayer);
}
if (map.hasLayer(historyLayer)) {
map.removeLayer(historyLayer);
clearHistoryLayer();
}
if (heatLayer && map.hasLayer(heatLayer)) {
map.removeLayer(heatLayer);
}
if (coverageLayer && map.hasLayer(coverageLayer)) {
map.removeLayer(coverageLayer);
}
if (map.hasLayer(radarLayerGroup)) {
map.removeLayer(radarLayerGroup);
}
if (map.hasLayer(weatherWindLayer)) {
map.removeLayer(weatherWindLayer);
}
stopRadarPreload(false);
stopRadarRefresh();
stopWeatherWindRefresh();
if (map.hasLayer(peerLayer)) {
map.removeLayer(peerLayer);
}
stopArcadeLoop();
} else if (map.hasLayer(trailLayer)) {
map.removeLayer(trailLayer);
} else {
if (map.hasLayer(routeLayer)) {
map.removeLayer(routeLayer);
}
if (map.hasLayer(arcadeLayer)) {
map.removeLayer(arcadeLayer);
}
if (map.hasLayer(historyLayer)) {
map.removeLayer(historyLayer);
clearHistoryLayer();
}
if (heatLayer && map.hasLayer(heatLayer)) {
map.removeLayer(heatLayer);
}
if (map.hasLayer(radarLayerGroup)) {
map.removeLayer(radarLayerGroup);
}
if (map.hasLayer(weatherWindLayer)) {
map.removeLayer(weatherWindLayer);
}
stopWeatherWindRefresh();
if (map.hasLayer(peerLayer)) {
map.removeLayer(peerLayer);
}
stopArcadeLoop();
}
}
function updateHistoryPanelVisibility() {
if (!historyPanel) return;
const shouldShow = historyVisible && !historyPanelHidden;
historyPanel.classList.toggle('active', shouldShow);
if (shouldShow) {
historyPanel.removeAttribute('hidden');
historyPanel.style.display = 'block';
} else {
historyPanel.setAttribute('hidden', 'hidden');
historyPanel.style.display = 'none';
}
}
function setHistoryPanelHidden(hidden) {
historyPanelHidden = Boolean(hidden);
if (historyPanelHidden) {
setHistoryPanelCollapsed(true);
}
updateHistoryPanelVisibility();
layoutSidePanels();
}
function setHistoryVisible(visible) {
historyVisible = visible;
if (visible) {
historyPanelHidden = false;
setHistoryPanelCollapsed(false);
}
const btn = document.getElementById('history-toggle');
if (btn) {
btn.classList.toggle('active', visible);
btn.textContent = visible ? 'History: on' : 'History tool';
}
updateHistoryPanelVisibility();
if (historyLegendGroup) {
historyLegendGroup.classList.toggle('active', visible);
}
if (visible) {
if (!map.hasLayer(historyLayer)) {
historyLayer.addTo(map);
}
renderHistoryFromCache();
updateHistoryRendering();
} else if (map.hasLayer(historyLayer)) {
map.removeLayer(historyLayer);
clearHistoryLayer();
}
layoutSidePanels();
}
function setHopsVisible(visible) {
hopsVisible = visible;
const btn = document.getElementById('hops-toggle');
if (btn) {
btn.classList.toggle('active', visible);
btn.textContent = visible ? 'Hide hops' : 'Show hops';
}
if (visible) {
if (!map.hasLayer(hopLayer)) {
hopLayer.addTo(map);
}
// Render for all existing routes
for (const [id, entry] of routeLines.entries()) {
renderHopMarkers(id, entry.meta);
}
} else {
if (map.hasLayer(hopLayer)) {
map.removeLayer(hopLayer);
}
hopMarkers.forEach(markers => {
markers.forEach(m => hopLayer.removeLayer(m));
});
hopMarkers.clear();
if (routeDetailsPanel) {
routeDetailsPanel.hidden = true;
routeDetailsPanel.classList.remove('active');
routeDetailsPanel.style.display = 'none';
}
activeRouteDetailsMeta = null;
activeRouteDetailsId = null;
layoutSidePanels();
}
}
function makeArcadeIcon() {
return L.divIcon({
className: 'flow-marker',
html: '<div class="flow-icon"></div>',
iconSize: [22, 22],
iconAnchor: [11, 11],
});
}
function buildArcadePath(points) {
if (!Array.isArray(points) || points.length < 2) return null;
const latLngs = points.map((point) => L.latLng(point[0], point[1]));
const segments = [];
let total = 0;
for (let idx = 0; idx < latLngs.length - 1; idx += 1) {
const start = latLngs[idx];
const end = latLngs[idx + 1];
const length = start.distanceTo(end);
if (!(length > 0)) continue;
segments.push({
start,
end,
startDistance: total,
length,
});
total += length;
}
if (!(total > 0) || !segments.length) return null;
return { latLngs, segments, total };
}
function interpolateArcadePosition(path, distance) {
if (!path || !Array.isArray(path.segments) || !path.segments.length) return null;
const clamped = Math.max(0, Math.min(distance, path.total));
for (let idx = 0; idx < path.segments.length; idx += 1) {
const segment = path.segments[idx];
const segmentEnd = segment.startDistance + segment.length;
if (clamped <= segmentEnd || idx === path.segments.length - 1) {
const ratio = segment.length > 0
? (clamped - segment.startDistance) / segment.length
: 0;
const lat = segment.start.lat + ((segment.end.lat - segment.start.lat) * ratio);
const lng = segment.start.lng + ((segment.end.lng - segment.start.lng) * ratio);
const startPoint = map.latLngToLayerPoint(segment.start);
const endPoint = map.latLngToLayerPoint(segment.end);
const angle = Math.atan2(
endPoint.y - startPoint.y,
endPoint.x - startPoint.x
) * (180 / Math.PI);
return { latlng: L.latLng(lat, lng), angle };
}
}
return null;
}
function stopArcadeLoop() {
if (arcadeAnimFrame != null) {
window.cancelAnimationFrame(arcadeAnimFrame);
arcadeAnimFrame = null;
}
}
function updateArcadeMarker(entry, nowMs) {
if (!entry || !entry.arcadeMarker || !entry.arcadeMarker.marker || !entry.arcadeMarker.path) return;
const elapsed = Math.max(0, nowMs - entry.arcadeMarker.startedAt);
const cycle = entry.arcadeMarker.durationMs > 0
? (elapsed % entry.arcadeMarker.durationMs)
: 0;
const distance = entry.arcadeMarker.path.total * (cycle / entry.arcadeMarker.durationMs);
const resolved = interpolateArcadePosition(entry.arcadeMarker.path, distance);
if (!resolved) return;
entry.arcadeMarker.marker.setLatLng(resolved.latlng);
const el = entry.arcadeMarker.marker.getElement();
const icon = el ? el.querySelector('.flow-icon') : null;
if (icon) {
icon.style.transform = `rotate(${resolved.angle}deg)`;
}
}
function animateArcadeMarkers(nowMs) {
if (!arcadeModeEnabled || !nodesVisible || !map.hasLayer(arcadeLayer)) {
stopArcadeLoop();
return;
}
let activeCount = 0;
routeLines.forEach((entry) => {
if (!entry || !entry.arcadeMarker || !entry.arcadeMarker.marker || !entry.arcadeMarker.path) return;
activeCount += 1;
updateArcadeMarker(entry, nowMs);
});
if (!activeCount) {
stopArcadeLoop();
return;
}
arcadeAnimFrame = window.requestAnimationFrame(animateArcadeMarkers);
}
function startArcadeLoop() {
if (arcadeAnimFrame != null || !arcadeModeEnabled || !nodesVisible || !map.hasLayer(arcadeLayer)) {
return;
}
arcadeAnimFrame = window.requestAnimationFrame(animateArcadeMarkers);
}
function removeArcadeMarker(routeId) {
const entry = routeLines.get(routeId);
if (!entry || !entry.arcadeMarker || !entry.arcadeMarker.marker) return;
arcadeLayer.removeLayer(entry.arcadeMarker.marker);
entry.arcadeMarker = null;
}
function syncArcadeForRoute(id, points) {
const entry = routeLines.get(id);
if (!entry) return;
if (!arcadeModeEnabled || !nodesVisible || !Array.isArray(points) || points.length < 2) {
removeArcadeMarker(id);
return;
}
const path = buildArcadePath(points);
if (!path) {
removeArcadeMarker(id);
return;
}
if (!entry.arcadeMarker || !entry.arcadeMarker.marker) {
entry.arcadeMarker = {
marker: L.marker(path.latLngs[0], {
icon: makeArcadeIcon(),
pane: 'arcadePane',
interactive: false,
keyboard: false,
}).addTo(arcadeLayer),
startedAt: performance.now(),
durationMs: FLOW_MIN_DURATION_MS,
path,
};
}
entry.arcadeMarker.path = path;
entry.arcadeMarker.durationMs = clampNumber(path.total * 6, FLOW_MIN_DURATION_MS, FLOW_MAX_DURATION_MS);
if (!Number.isFinite(entry.arcadeMarker.startedAt)) {
entry.arcadeMarker.startedAt = performance.now();
}
updateArcadeMarker(entry, performance.now());
startArcadeLoop();
}
function toggleArcadeMode() {
setArcadeModeEnabled(!arcadeModeEnabled);
console.info(`[meshmap] arcade mode ${arcadeModeEnabled ? 'on' : 'off'}`);
}
const ARCADE_SEQUENCE = [
'ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown',
'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight',
'b', 'a'
];
let arcadeSequenceIndex = 0;
function handleArcadeSequenceKey(ev) {
const target = ev.target;
if (
target &&
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
) {
return;
}
const key = String(ev.key || '');
const expected = ARCADE_SEQUENCE[arcadeSequenceIndex];
if (key === expected) {
arcadeSequenceIndex += 1;
if (arcadeSequenceIndex >= ARCADE_SEQUENCE.length) {
arcadeSequenceIndex = 0;
toggleArcadeMode();
}
return;
}
arcadeSequenceIndex = key === ARCADE_SEQUENCE[0] ? 1 : 0;
}
document.addEventListener('keydown', handleArcadeSequenceKey);
function stringHashColor(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
const c = (hash & 0x00FFFFFF).toString(16).toUpperCase();
return '#' + '00000'.substring(0, 6 - c.length) + c;
}
function renderHopMarkers(routeId, meta) {
if (!hopsVisible || !meta || !meta.points) return;
// Clear existing for this route
if (hopMarkers.has(routeId)) {
hopMarkers.get(routeId).forEach(m => hopLayer.removeLayer(m));
hopMarkers.delete(routeId);
}
// Generate color
const color = stringHashColor(routeId);
const markersList = [];
meta.points.forEach((pt, index) => {
// Skip 0 (origin) and last (destination) if we only want hops?
// "displays the hop number" - usually means 1, 2, 3...
// The request says "route path on the map".
// "User clicks on ... hop number ... displays hop number alongside repeater name"
// Usually we want to see intermediate hops.
// If points includes origin and dest, index 0 is origin (hop 0?), index N is dest.
// Let's show all for completeness or just intermediates?
// "Route Details" implies showing the whole path.
// Let's show all points with their index.
const lat = pt.lat;
const lon = pt.lon;
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
const icon = L.divIcon({
className: 'hop-marker-container', // We'll use innerHTML for styling
html: `<div class="hop-marker" style="background-color: ${color}; width: 16px; height: 16px;">${index}</div>`,
iconSize: [16, 16],
iconAnchor: [8, 8]
});
const marker = L.marker([lat, lon], { icon, interactive: true }).addTo(hopLayer);
marker.on('click', (ev) => {
L.DomEvent.stop(ev);
showRouteDetails(meta);
});
markersList.push(marker);
});
hopMarkers.set(routeId, markersList);
}
function showRouteDetails(meta) {
if (!meta) return;
activeRouteDetailsMeta = meta;
activeRouteDetailsId = meta.id || null;
// Show panel
if (routeDetailsPanel) {
routeDetailsPanel.classList.add('active');
routeDetailsPanel.hidden = false;
routeDetailsPanel.style.display = 'block';
setRouteDetailsPanelCollapsed(false);
}
if (routeDetailsTitle) {
const routeHash = typeof meta.message_hash === 'string' && meta.message_hash.trim()
? meta.message_hash.trim()
: (typeof meta.id === 'string' ? meta.id.trim() : '');
const routeLabel = routeHash || 'unknown';
routeDetailsTitle.innerHTML = '';
routeDetailsTitle.append(document.createTextNode('Route: '));
if (packetAnalyzerUrl && routeHash) {
const link = document.createElement('a');
link.href = `${packetAnalyzerUrl}${encodeURIComponent(routeHash)}`;
link.target = '_blank';
link.rel = 'noopener';
link.className = 'route-hash-link';
link.textContent = routeLabel;
link.title = routeHash;
routeDetailsTitle.append(link);
} else {
routeDetailsTitle.append(document.createTextNode(routeLabel));
}
routeDetailsTitle.append(document.createTextNode(` (${meta.hop_count} hops)`));
}
if (routeDetailsTotal) {
const totalDistance = Number.isFinite(meta.distance_m)
? formatDistanceUnits(meta.distance_m)
: null;
routeDetailsTotal.textContent = totalDistance ? `Total distance: ${totalDistance}` : '';
}
if (routeDetailsContent) {
routeDetailsContent.innerHTML = '';
// Generate list
const color = stringHashColor(meta.id);
const displayPoints = meta.points.map((pt) => ({ ...pt }));
const displayHashes = Array.isArray(meta.hashes) ? [...meta.hashes] : [];
const resolvePointName = (pt, displayIdx) => {
const isFirst = displayIdx === 0;
const isLast = displayIdx === displayPoints.length - 1;
let label = pt.point_label && pt.point_label !== 'Unknown' ? pt.point_label : null;
if (!label && isFirst && meta.origin_label && !meta.origin_label.toLowerCase().includes('packet')) {
label = meta.origin_label;
}
if (!label && isLast && meta.receiver_label && !meta.receiver_label.toLowerCase().includes('packet')) {
label = meta.receiver_label;
}
if (!label && pt.point_id) {
label = deviceLabelFromId(pt.point_id);
}
if (!label) {
return isFirst ? 'Origin' : (isLast ? 'Receiver' : `Hop ${displayIdx}`);
}
return label;
};
displayPoints.forEach((pt, displayIdx) => {
const row = document.createElement('div');
row.className = 'hop-row';
const paramName = resolvePointName(pt, displayIdx);
const distInfo = Number.isFinite(pt.hop_distance_m)
? formatDistanceUnits(pt.hop_distance_m)
: '';
let idInfo = '';
if (pt.node_prefix) {
idInfo = `Prefix: ${pt.node_prefix}`;
}
let roleInfo = '';
if (pt.role_label) {
roleInfo = `Role: ${pt.role_label}`;
}
let endpointInfo = '';
if (pt.endpoint_only && pt.endpoint_kind === 'sender_name') {
endpointInfo = 'Sender name';
} else if (pt.endpoint_only && pt.endpoint_kind === 'origin') {
endpointInfo = 'Origin endpoint';
} else if (pt.endpoint_only && pt.endpoint_kind === 'receiver') {
endpointInfo = 'Receiver endpoint';
}
const metaInfo = [endpointInfo, roleInfo, distInfo, idInfo].filter(Boolean).join(' • ');
let badgeContent = displayIdx;
let badgeClass = 'hop-badge';
let badgeStyle = `background-color: ${color}`;
row.innerHTML = `
<div class="${badgeClass}" style="${badgeStyle}">${badgeContent}</div>
<div class="hop-info">
<div class="hop-name" title="${paramName}">${paramName}</div>
<div class="hop-meta">${metaInfo}</div>
</div>
`;
routeDetailsContent.appendChild(row);
});
}
layoutSidePanels();
}
function setPeersStatus(text) {
if (peersStatus) {
peersStatus.textContent = text || '';
}
}
function clearPeerLines() {
peerLines.forEach(line => {
if (line && peerLayer.hasLayer(line)) {
peerLayer.removeLayer(line);
}
});
peerLines.clear();
if (map.hasLayer(peerLayer)) {
map.removeLayer(peerLayer);
}
}
function renderPeerLines(origin, incoming, outgoing) {
clearPeerLines();
if (!peersActive || !nodesVisible) return;
if (!origin || origin.lat == null || origin.lon == null) return;
if (!map.hasLayer(peerLayer)) {
peerLayer.addTo(map);
}
const originLatLng = [origin.lat, origin.lon];
const drawLine = (peer, color, dash, direction) => {
if (peer.lat == null || peer.lon == null) return;
const line = L.polyline([originLatLng, [peer.lat, peer.lon]], {
pane: 'peerPane',
color,
weight: 3,
opacity: 0.85,
dashArray: dash,
interactive: false
}).addTo(peerLayer);
const key = `${direction}:${peer.peer_id || `${peer.lat},${peer.lon}`}`;
peerLines.set(key, line);
};
incoming.forEach(peer => drawLine(peer, '#38bdf8', '6 8', 'in'));
outgoing.forEach(peer => drawLine(peer, '#a855f7', '2 6', 'out'));
if (peerLayer.bringToFront) {
peerLayer.bringToFront();
}
}
function renderPeerList(target, peers, total, label) {
if (!target) return;
target.innerHTML = '';
if (!peers || peers.length === 0) {
const empty = document.createElement('div');
empty.className = 'small';
empty.textContent = `No ${label} data yet.`;
target.appendChild(empty);
return;
}
peers.forEach(peer => {
const item = document.createElement('div');
item.className = 'peer-item';
const name = peer.name || (peer.peer_id ? `${peer.peer_id.slice(0, 8)}` : 'Unknown');
const percent = total > 0 ? `${peer.percent.toFixed(1)}%` : '0%';
item.innerHTML = `<span class="peer-name">${name}</span><span class="peer-count">${peer.count}${percent}</span>`;
item.addEventListener('click', () => {
if (peer.peer_id) {
focusDevice(peer.peer_id);
}
});
target.appendChild(item);
});
}
async function selectPeerNode(deviceId) {
if (!deviceId) return;
peersSelectedId = deviceId;
if (!peersActive) return;
const requestToken = ++peersRequestToken;
setPeersStatus('Loading peers…');
if (peersMeta) peersMeta.textContent = '';
try {
const res = await fetch(withToken(`/peers/${encodeURIComponent(deviceId)}`), { headers: tokenHeaders() });
if (!res.ok) {
throw new Error(`peers ${res.status}`);
}
const data = await res.json();
if (requestToken !== peersRequestToken || !peersActive || peersSelectedId !== deviceId) return;
peersData = data;
const name = data.name || (deviceId ? `${deviceId.slice(0, 8)}` : 'Unknown');
const inboundTotal = data.incoming_total || 0;
const outboundTotal = data.outgoing_total || 0;
setPeersStatus(`${name} peers`);
if (peersMeta) {
peersMeta.textContent = `Incoming ${inboundTotal} • Outgoing ${outboundTotal}${data.window_hours || 24}h window`;
}
renderPeerList(peersIn, data.incoming || [], inboundTotal, 'incoming');
renderPeerList(peersOut, data.outgoing || [], outboundTotal, 'outgoing');
renderPeerLines(
{ lat: data.lat, lon: data.lon },
data.incoming || [],
data.outgoing || []
);
} catch (err) {
if (requestToken !== peersRequestToken) return;
setPeersStatus('Peer lookup failed.');
if (peersMeta) peersMeta.textContent = '';
renderPeerList(peersIn, [], 0, 'incoming');
renderPeerList(peersOut, [], 0, 'outgoing');
peersData = null;
clearPeerLines();
}
}
function clearPeers() {
peersRequestToken += 1;
peersSelectedId = null;
peersData = null;
setPeersStatus('Select a node to view peers.');
if (peersMeta) peersMeta.textContent = '';
renderPeerList(peersIn, [], 0, 'incoming');
renderPeerList(peersOut, [], 0, 'outgoing');
clearPeerLines();
}
function setPeersActive(active) {
peersActive = active;
if (peersToggle) {
peersToggle.classList.toggle('active', active);
peersToggle.textContent = active ? 'Peers: select node' : 'Peers tool';
}
if (peersPanel) {
peersPanel.classList.toggle('active', active);
if (active) {
setPeersPanelCollapsed(false);
peersPanel.removeAttribute('hidden');
peersPanel.style.display = 'block';
} else {
peersPanel.setAttribute('hidden', 'hidden');
peersPanel.style.display = 'none';
}
}
if (active && peersData) {
renderPeerLines(
{ lat: peersData.lat, lon: peersData.lon },
peersData.incoming || [],
peersData.outgoing || []
);
}
if (!active) {
clearPeers();
}
layoutSidePanels();
}
function setHeatVisible(visible) {
heatVisible = visible;
const btn = document.getElementById('heat-toggle');
if (!heatAvailable) {
heatVisible = false;
if (btn) {
btn.disabled = true;
btn.classList.add('disabled');
btn.textContent = 'Heat unavailable';
}
return;
}
if (btn) {
btn.classList.toggle('active', !visible);
btn.textContent = visible ? 'Hide heat' : 'Show heat';
}
if (!nodesVisible) {
if (heatLayer && map.hasLayer(heatLayer)) {
map.removeLayer(heatLayer);
}
return;
}
if (visible) {
if (heatLayer && !map.hasLayer(heatLayer)) {
heatLayer.addTo(map);
}
} else if (heatLayer && map.hasLayer(heatLayer)) {
map.removeLayer(heatLayer);
}
}
function setLosStatus(text) {
const el = document.getElementById('los-status');
if (el) {
el.textContent = text || '';
}
}
function deviceShortId(d) {
return d.device_id ? `${d.device_id.slice(0, 8)}` : '';
}
function deviceDisplayName(d) {
return d.name || deviceShortId(d) || 'Unknown';
}
function getLastSeenTs(d) {
return d.last_seen_ts || d.ts;
}
function isMqttOnline(d) {
if (d.mqtt_forced) return true;
const now = getServerNowMs() / 1000;
const statusValue = String(d.mqtt_status_value || '').trim().toLowerCase();
const statusTs = Number(d.mqtt_status_ts) || 0;
if (
statusTs &&
mqttOnlineStatusTtlSeconds > 0 &&
(now - statusTs) <= mqttOnlineStatusTtlSeconds &&
(statusValue === 'offline' || statusValue === 'disconnected')
) {
return false;
}
const source = String(d.mqtt_online_source || '').trim().toLowerCase();
if (source === 'internal') {
const ts = Number(d.mqtt_internal_ts) || Number(d.mqtt_seen_ts) || 0;
return ts > 0 && mqttOnlineInternalTtlSeconds > 0 && (now - ts) <= mqttOnlineInternalTtlSeconds;
}
if (source === 'status') {
const ts = Number(d.mqtt_status_ts) || Number(d.mqtt_seen_ts) || 0;
return ts > 0 && mqttOnlineStatusTtlSeconds > 0 && (now - ts) <= mqttOnlineStatusTtlSeconds;
}
const lastSeen = Number(d.mqtt_seen_ts) || 0;
if (!lastSeen) return false;
return mqttOnlineSeconds > 0 && (now - lastSeen) <= mqttOnlineSeconds;
}
function updateMarkerLabel(m, d) {
if (!m || !d) return;
if (!showLabels) {
if (m.getTooltip()) m.unbindTooltip();
return;
}
const label = deviceDisplayName(d);
if (!label) return;
if (m.getTooltip()) {
m.setTooltipContent(label);
} else {
m.bindTooltip(label, {
permanent: true,
direction: 'top',
className: 'node-label',
offset: [0, -6]
});
}
}
function renderSearchResults(query) {
if (!searchResults) return;
const q = (query || '').trim().toLowerCase();
searchMatches = [];
if (!q) {
searchResults.hidden = true;
searchResults.innerHTML = '';
return;
}
for (const [id, d] of deviceData.entries()) {
const name = (d.name || '').toLowerCase();
if (name.includes(q) || id.toLowerCase().includes(q)) {
searchMatches.push({ id, d });
}
}
searchMatches = searchMatches.slice(0, 8);
if (searchMatches.length === 0) {
searchResults.hidden = true;
searchResults.innerHTML = '';
return;
}
searchResults.innerHTML = '';
searchMatches.forEach(({ id, d }) => {
const item = document.createElement('div');
item.className = 'node-search-item';
item.innerHTML = `<span>${deviceDisplayName(d)}</span><span class="node-search-id">${id.slice(0, 8)}…</span>`;
item.addEventListener('click', () => {
if (peersActive) {
selectPeerNode(id);
}
focusDevice(id);
});
searchResults.appendChild(item);
});
searchResults.hidden = false;
}
function focusDevice(id) {
const marker = markers.get(id);
const d = deviceData.get(id);
if (!marker || !d) return;
const targetZoom = Math.max(map.getZoom(), 13);
map.flyTo(marker.getLatLng(), targetZoom, { duration: 0.6 });
marker.openPopup();
if (searchInput) searchInput.value = '';
if (searchResults) {
searchResults.hidden = true;
searchResults.innerHTML = '';
}
}
function setLabelsActive(active) {
showLabels = active;
localStorage.setItem('meshmapShowLabels', showLabels ? 'true' : 'false');
markers.forEach((m, id) => {
const d = deviceData.get(id);
if (d) updateMarkerLabel(m, d);
});
const labelsToggle = document.getElementById('labels-toggle');
if (labelsToggle) {
labelsToggle.textContent = showLabels ? 'Labels On' : 'Labels Off';
labelsToggle.classList.toggle('active', showLabels);
}
}
function clearLosProfile() {
if (!losProfile || !losProfileSvg) return;
losProfile.hidden = true;
losProfileSvg.innerHTML = '';
if (losProfileTooltip) {
losProfileTooltip.hidden = true;
losProfileTooltip.textContent = '';
}
losProfileData = [];
losProfileMeta = null;
layoutSidePanels();
}
function clearLosPeaks() {
losPeakMarkers.forEach(item => {
const marker = item && item.marker ? item.marker : item;
if (marker) {
losLayer.removeLayer(marker);
}
});
losPeakMarkers = [];
losActivePeak = null;
}
function clearLosHoverMarker() {
if (losHoverMarker) {
losLayer.removeLayer(losHoverMarker);
losHoverMarker = null;
}
if (losActivePeak && losActivePeak.marker) {
losActivePeak.marker.setStyle({
radius: 4,
color: '#f59e0b',
fillColor: '#fbbf24',
weight: 2,
fillOpacity: 0.95
});
losActivePeak = null;
}
}
function getLosSegmentLabel(index) {
if (!Number.isInteger(index) || index < 0) return '';
return `${index + 1}${index + 2}`;
}
function buildCombinedLosProfile() {
const combined = [];
const segmentRanges = [];
let offset = 0;
let anyBlocked = false;
losSegmentMeta.forEach((meta, index) => {
const profile = Array.isArray(meta?.profile) ? meta.profile : null;
if (!profile || profile.length < 2) return;
const startDistance = offset;
profile.forEach((point, pointIndex) => {
const distance = Number(point[0]);
const terrain = Number(point[1]);
const losLineValue = Number(point[2]);
if (![distance, terrain, losLineValue].every(Number.isFinite)) return;
if (index > 0 && pointIndex === 0) return;
combined.push([offset + distance, terrain, losLineValue]);
});
const segmentDistance = Number(profile[profile.length - 1][0]) || 0;
const endDistance = offset + segmentDistance;
segmentRanges[index] = { startDistance, endDistance };
offset = endDistance;
anyBlocked = anyBlocked || !!meta?.blocked;
});
return { profile: combined, blocked: anyBlocked, segmentRanges };
}
function resolveLosProfileDistance(distanceMeters) {
const ranges = Array.isArray(losProfileMeta?.segmentRanges) ? losProfileMeta.segmentRanges : [];
if (!ranges.length) {
const endpoints = getActiveLosSegmentEndpoints();
if (!endpoints) return null;
return { segmentIndex: losActiveSegmentIndex, localDistance: distanceMeters, endpoints };
}
const clamped = Math.min(Math.max(distanceMeters, 0), losProfileMeta.totalDistance || 0);
for (let index = 0; index < ranges.length; index++) {
const range = ranges[index];
if (!range) continue;
const isLast = index === ranges.length - 1;
if (clamped < range.endDistance || isLast) {
const endpoints = getLosSegmentEndpoints(index);
if (!endpoints) return null;
return {
segmentIndex: index,
localDistance: Math.max(0, clamped - range.startDistance),
endpoints
};
}
}
return null;
}
function getLosSegmentEndpoints(index) {
if (!Number.isInteger(index) || index < 0 || (index + 1) >= losPoints.length) return null;
return [losPoints[index], losPoints[index + 1]];
}
function getActiveLosSegmentEndpoints() {
return getLosSegmentEndpoints(losActiveSegmentIndex);
}
function getActiveLosLine() {
if (!Number.isInteger(losActiveSegmentIndex) || losActiveSegmentIndex < 0) return null;
return losLines[losActiveSegmentIndex] || null;
}
function updateLosSegmentStyles() {
losLines.forEach((line, index) => {
if (!line) return;
const meta = losSegmentMeta[index] || null;
const active = index === losActiveSegmentIndex;
const color = meta ? (meta.blocked ? '#ef4444' : '#22c55e') : '#9ca3af';
line.setStyle({
color,
weight: active ? 5 : 4,
opacity: active ? 0.9 : 0.75,
dashArray: '6 10'
});
});
}
function selectLosSegment(index, options = {}) {
if (!Number.isInteger(index) || index < 0 || index >= losLines.length) return;
losActiveSegmentIndex = index;
syncLosHeightInputs();
updateLosSegmentStyles();
const meta = losSegmentMeta[index] || null;
const combined = buildCombinedLosProfile();
if (combined.profile.length >= 2) {
renderLosProfile(combined.profile, combined.blocked, combined);
}
if (meta) {
lastLosStatusMeta = meta;
setLosStatus(buildLosStatus(meta));
} else if (options.silent !== true) {
setLosStatus(`LOS: selected segment ${getLosSegmentLabel(index)}`);
}
if (options.recompute === true) {
scheduleLosCheck(true, { allowNetwork: true, forceNetwork: true });
}
}
function ensureLosSegments() {
const needed = Math.max(0, losPoints.length - 1);
while (losLines.length > needed) {
const line = losLines.pop();
if (line) {
losLayer.removeLayer(line);
}
}
while (losSegmentMeta.length > needed) {
losSegmentMeta.pop();
}
for (let index = 0; index < needed; index++) {
const endpoints = getLosSegmentEndpoints(index);
if (!endpoints) continue;
const latlngs = [endpoints[0], endpoints[1]];
let line = losLines[index];
if (!line) {
line = L.polyline(latlngs, {
color: '#9ca3af',
weight: 4,
opacity: 0.8,
dashArray: '6 10'
}).addTo(losLayer);
line.__losSegmentIndex = index;
line.on('mousemove', (ev) => {
if (line.__losSegmentIndex !== losActiveSegmentIndex) return;
if (ev && ev.latlng) {
updateLosProfileFromMap(ev.latlng);
}
});
line.on('mouseout', clearLosProfileHover);
line.on('click', (ev) => {
if (ev && ev.originalEvent) {
ev.originalEvent.preventDefault();
ev.originalEvent.stopPropagation();
}
if (typeof L !== 'undefined' && L.DomEvent) {
L.DomEvent.stop(ev);
}
selectLosSegment(line.__losSegmentIndex, { recompute: true });
});
losLines[index] = line;
} else {
line.__losSegmentIndex = index;
line.setLatLngs(latlngs);
}
}
if (needed === 0) {
losActiveSegmentIndex = null;
} else if (losActiveSegmentIndex == null || losActiveSegmentIndex >= needed) {
losActiveSegmentIndex = needed - 1;
}
updateLosSegmentStyles();
}
function setPropStatus(text) {
const el = document.getElementById('prop-status');
if (el) {
el.textContent = text || '';
}
}
function setPropRange(text) {
const el = document.getElementById('prop-range');
if (el) {
el.textContent = text || '';
}
}
function setPropCost(text) {
const el = document.getElementById('prop-cost');
if (el) {
el.textContent = text || '';
}
}
function layoutSidePanels() {
if (!losPanel || !propPanel) return;
const panels = [];
if (losActive && losPanel.classList.contains('active')) panels.push(losPanel);
if (radarVisible && weatherPanel && weatherPanel.classList.contains('active')) panels.push(weatherPanel);
if (historyVisible && historyPanel && historyPanel.classList.contains('active')) panels.push(historyPanel);
if (peersActive && peersPanel && peersPanel.classList.contains('active')) panels.push(peersPanel);
if (routeDetailsPanel && routeDetailsPanel.classList.contains('active')) panels.push(routeDetailsPanel);
if (propagationActive && propPanel.classList.contains('active')) panels.push(propPanel);
const allPanels = [losPanel, weatherPanel, historyPanel, peersPanel, routeDetailsPanel, propPanel];
allPanels.forEach(panel => {
if (!panel) return;
panel.style.top = '';
panel.style.bottom = '';
panel.style.maxHeight = '';
});
if (!panels.length) return;
const gap = 12;
const availableHeight = Math.max(200, window.innerHeight - 24);
const totalHeight = panels.reduce((sum, panel) => sum + (panel.offsetHeight || 0), 0)
+ (panels.length - 1) * gap;
if (totalHeight > availableHeight) {
const maxPer = Math.max(
140,
Math.floor((availableHeight - (panels.length - 1) * gap) / panels.length)
);
panels.forEach(panel => {
panel.style.maxHeight = `${maxPer}px`;
});
}
const isMobile = window.matchMedia('(max-width: 900px)').matches;
if (isMobile) {
let bottom = 12;
panels.slice().reverse().forEach(panel => {
panel.style.bottom = `${bottom}px`;
bottom += (panel.offsetHeight || 0) + gap;
});
return;
}
let top = 18;
panels.forEach(panel => {
panel.style.top = `${top}px`;
top += (panel.offsetHeight || 0) + gap;
});
}
function setToolPanelCollapsed(panel, button, collapsed, storageKey) {
if (!panel || !button) return;
panel.classList.toggle('panel-collapsed', collapsed);
button.textContent = collapsed ? 'Expand' : 'Minimize';
button.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
try {
localStorage.setItem(storageKey, collapsed ? 'true' : 'false');
} catch (_err) {
// ignore storage failures
}
layoutSidePanels();
}
function setLosPanelCollapsed(collapsed) {
losPanelCollapsed = Boolean(collapsed);
setToolPanelCollapsed(
losPanel,
losPanelCollapse,
losPanelCollapsed,
LOS_PANEL_COLLAPSED_KEY
);
}
function setPropPanelCollapsed(collapsed) {
propPanelCollapsed = Boolean(collapsed);
setToolPanelCollapsed(
propPanel,
propPanelCollapse,
propPanelCollapsed,
PROP_PANEL_COLLAPSED_KEY
);
}
function setHistoryPanelCollapsed(collapsed) {
historyPanelCollapsed = Boolean(collapsed);
setToolPanelCollapsed(
historyPanel,
historyPanelCollapse,
historyPanelCollapsed,
HISTORY_PANEL_COLLAPSED_KEY
);
}
function setPeersPanelCollapsed(collapsed) {
peersPanelCollapsed = Boolean(collapsed);
setToolPanelCollapsed(
peersPanel,
peersPanelCollapse,
peersPanelCollapsed,
PEERS_PANEL_COLLAPSED_KEY
);
}
function setRouteDetailsPanelCollapsed(collapsed) {
routeDetailsPanelCollapsed = Boolean(collapsed);
setToolPanelCollapsed(
routeDetailsPanel,
routeDetailsCollapse,
routeDetailsPanelCollapsed,
ROUTE_DETAILS_PANEL_COLLAPSED_KEY
);
}
function clearLos() {
losPoints = [];
losPointHeights = [];
persistLosPointHeights();
losLines = [];
losSegmentMeta = [];
losActiveSegmentIndex = null;
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 = [];
syncLosHeightInputs();
}
function setLosActive(active) {
losActive = active;
const btn = document.getElementById('los-toggle');
if (btn) {
btn.classList.toggle('active', active);
btn.textContent = active ? 'LOS: add pins' : 'LOS tool';
}
if (losLegendGroup) {
losLegendGroup.classList.toggle('active', active);
}
if (losPanel) {
losPanel.classList.toggle('active', active);
}
setLosPanelCollapsed(losPanelCollapsed);
if (!active) {
clearLos();
} else {
losLocked = false;
setLosStatus('LOS: select first point (Shift+click or long-press nodes)');
}
layoutSidePanels();
}
function losFrequencyHz() {
return PROP_DEFAULTS.freqMHz * 1e6;
}
function losFirstFresnelRadiusMeters(distanceMeters, sampleDistanceMeters) {
if (!(distanceMeters > 0) || !(sampleDistanceMeters >= 0)) return 0;
const d1 = sampleDistanceMeters;
const d2 = distanceMeters - d1;
if (!(d2 >= 0)) return 0;
const frequencyHz = losFrequencyHz();
if (!(frequencyHz > 0)) return 0;
const wavelengthMeters = 299792458 / frequencyHz;
return Math.sqrt((wavelengthMeters * d1 * d2) / distanceMeters);
}
function losFresnelRadiusAtProfileDistance(distanceMeters, segmentRanges) {
const ranges = Array.isArray(segmentRanges) ? segmentRanges : [];
if (ranges.length) {
for (let index = 0; index < ranges.length; index += 1) {
const range = ranges[index];
if (!range) continue;
const isLast = index === ranges.length - 1;
if (distanceMeters < range.endDistance || isLast) {
const segmentDistance = Math.max(
0,
Number(range.endDistance) - Number(range.startDistance)
);
const localDistance = Math.max(
0,
distanceMeters - Number(range.startDistance || 0)
);
return losFirstFresnelRadiusMeters(segmentDistance, localDistance);
}
}
}
const totalDistance = ranges.length
? Math.max(0, Number(ranges[ranges.length - 1]?.endDistance || 0))
: Math.max(0, Number(distanceMeters));
return losFirstFresnelRadiusMeters(totalDistance, distanceMeters);
}
function renderLosProfile(profile, blocked, options = {}) {
if (!losProfile || !losProfileSvg) return;
if (!Array.isArray(profile) || profile.length < 2) {
clearLosProfile();
return;
}
const width = 300;
const height = 90;
const pad = 6;
const last = profile[profile.length - 1];
const totalDistance = Math.max(1, Number(last[0]) || 1);
const segmentRanges = Array.isArray(options.segmentRanges)
? options.segmentRanges
: [];
let minElev = Infinity;
let maxElev = -Infinity;
profile.forEach(item => {
const terrain = Number(item[1]);
const los = Number(item[2]);
const distance = Number(item[0]);
const fresnelRadius = losFresnelRadiusAtProfileDistance(
distance,
segmentRanges
);
const fresnelUpper = los + fresnelRadius;
const fresnelLower = los - fresnelRadius;
if (!Number.isNaN(terrain)) {
minElev = Math.min(minElev, terrain);
maxElev = Math.max(maxElev, terrain);
}
if (!Number.isNaN(los)) {
minElev = Math.min(minElev, los);
maxElev = Math.max(maxElev, los);
}
if (!Number.isNaN(fresnelUpper)) {
minElev = Math.min(minElev, fresnelUpper);
maxElev = Math.max(maxElev, fresnelUpper);
}
if (!Number.isNaN(fresnelLower)) {
minElev = Math.min(minElev, fresnelLower);
maxElev = Math.max(maxElev, fresnelLower);
}
});
if (!Number.isFinite(minElev) || !Number.isFinite(maxElev) || minElev === maxElev) {
clearLosProfile();
return;
}
const span = maxElev - minElev;
const innerWidth = width - pad * 2;
const innerHeight = height - pad * 2;
const toX = (d) => pad + (d / totalDistance) * innerWidth;
const toY = (e) => height - pad - ((e - minElev) / span) * innerHeight;
const terrainPath = profile.map((item, idx) => {
const d = Number(item[0]);
const elev = Number(item[1]);
return `${idx === 0 ? 'M' : 'L'}${toX(d).toFixed(2)} ${toY(elev).toFixed(2)}`;
}).join(' ');
const losPath = profile.map((item, idx) => {
const d = Number(item[0]);
const elev = Number(item[2]);
return `${idx === 0 ? 'M' : 'L'}${toX(d).toFixed(2)} ${toY(elev).toFixed(2)}`;
}).join(' ');
const fresnelUpperPath = profile.map((item, idx) => {
const d = Number(item[0]);
const los = Number(item[2]);
const elev = los + losFresnelRadiusAtProfileDistance(d, segmentRanges);
return `${idx === 0 ? 'M' : 'L'}${toX(d).toFixed(2)} ${toY(elev).toFixed(2)}`;
}).join(' ');
const fresnelLowerPath = profile.map((item, idx) => {
const d = Number(item[0]);
const los = Number(item[2]);
const elev = los - losFresnelRadiusAtProfileDistance(d, segmentRanges);
return `${idx === 0 ? 'M' : 'L'}${toX(d).toFixed(2)} ${toY(elev).toFixed(2)}`;
}).join(' ');
const losColor = blocked ? '#ef4444' : '#22c55e';
losProfileSvg.setAttribute('viewBox', `0 0 ${width} ${height}`);
losProfileSvg.innerHTML = `
<path class="los-profile-terrain" d="${terrainPath}"></path>
<path class="los-profile-fresnel" d="${fresnelUpperPath}"></path>
<path class="los-profile-fresnel" d="${fresnelLowerPath}"></path>
<path class="los-profile-los" d="${losPath}" stroke="${losColor}"></path>
<line id="los-profile-cursor" x1="0" y1="0" x2="0" y2="${height}" stroke="rgba(255,255,255,.35)" stroke-width="1" opacity="0" />
<circle id="los-profile-point" cx="0" cy="0" r="3" fill="${losColor}" opacity="0" />
`;
losProfileData = profile;
losProfileMeta = {
width,
height,
pad,
minElev,
maxElev,
totalDistance,
innerWidth,
innerHeight,
blocked,
segmentRanges
};
losProfile.hidden = false;
layoutSidePanels();
}
function formatDistanceUnits(meters) {
if (meters == null) return '';
const value = Number(meters);
if (Number.isNaN(value)) return '';
if (distanceUnits === 'mi') {
const miles = value / 1609.344;
if (miles < 0.5) {
const feet = value * 3.28084;
return `${Math.round(feet)} ft`;
}
return `${miles.toFixed(2)} mi`;
}
if (value >= 1000) return `${(value / 1000).toFixed(2)} km`;
return `${Math.round(value)} m`;
}
function formatDistanceMeters(meters) {
return formatDistanceUnits(meters);
}
function formatObstructionUnits(meters) {
if (meters == null) return '';
const value = Number(meters);
if (Number.isNaN(value)) return '';
if (distanceUnits === 'mi') {
const feet = value * 3.28084;
return `${feet.toFixed(1)} ft`;
}
return `${value.toFixed(1)} m`;
}
function buildLosStatus(meta) {
if (!meta) return '';
const distance = meta.distance_m != null ? formatDistanceUnits(meta.distance_m) : '';
const obstruction = meta.blocked
? `Blocked (+${formatObstructionUnits(meta.obstruction_m)})`
: 'Clear';
const segmentLabel = meta.segment_label ? `${meta.segment_label}` : '';
let status = `LOS: ${segmentLabel}${distance}${obstruction}`;
if (meta.suggested) {
status += meta.suggested_clear
? ' • Relay Suggested'
: ' • Relay May Help (Still Blocked)';
}
return status;
}
function formatElevationMeters(meters) {
if (meters == null) return '';
const value = Number(meters);
if (Number.isNaN(value)) return '';
return `${value.toFixed(1)} m`;
}
function updateLosProfileCursor(distance, terrain, losLineValue) {
if (!losProfileMeta || !losProfileSvg || !losProfileTooltip) return;
if (!losProfileData || losProfileData.length < 2) return;
const total = losProfileMeta.totalDistance;
const clampedDistance = Math.min(Math.max(distance, 0), total);
const xSvg = losProfileMeta.pad + (clampedDistance / total) * losProfileMeta.innerWidth;
const ySvg = losProfileMeta.height - losProfileMeta.pad -
((terrain - losProfileMeta.minElev) / (losProfileMeta.maxElev - losProfileMeta.minElev)) * losProfileMeta.innerHeight;
const cursor = document.getElementById('los-profile-cursor');
const dot = document.getElementById('los-profile-point');
if (cursor) {
cursor.setAttribute('x1', xSvg.toFixed(2));
cursor.setAttribute('x2', xSvg.toFixed(2));
cursor.setAttribute('opacity', '1');
}
if (dot) {
dot.setAttribute('cx', xSvg.toFixed(2));
dot.setAttribute('cy', ySvg.toFixed(2));
dot.setAttribute('opacity', '1');
}
const fresnelRadius = losFirstFresnelRadiusMeters(
losProfileMeta.totalDistance,
clampedDistance
);
const segmentFresnelRadius = losFresnelRadiusAtProfileDistance(
clampedDistance,
losProfileMeta.segmentRanges
);
losProfileTooltip.hidden = false;
losProfileTooltip.textContent = `Distance ${formatDistanceMeters(clampedDistance)} • Terrain ${formatElevationMeters(terrain)} • LOS ${formatElevationMeters(losLineValue)} • Fresnel ${formatElevationMeters(segmentFresnelRadius || fresnelRadius)}`;
losProfileTooltip.style.left = `${xSvg}px`;
losProfileTooltip.style.top = `${ySvg}px`;
}
function updateLosPeakHighlight(distanceMeters) {
if (!losPeakMarkers.length || distanceMeters == null) {
if (losActivePeak) {
losActivePeak.marker.setStyle({
radius: 4,
color: '#f59e0b',
fillColor: '#fbbf24',
weight: 2,
fillOpacity: 0.95
});
if (losActivePeak.marker.closeTooltip) {
losActivePeak.marker.closeTooltip();
}
losActivePeak = null;
}
return null;
}
const threshold = Math.max(150, losSampleStepMeters * 0.75);
let best = null;
losPeakMarkers.forEach(item => {
if (!item || item.distance == null) return;
const delta = Math.abs(item.distance - distanceMeters);
if (delta <= threshold && (!best || delta < best.delta)) {
best = { item, delta };
}
});
if (!best) {
if (losActivePeak) {
losActivePeak.marker.setStyle({
radius: 4,
color: '#f59e0b',
fillColor: '#fbbf24',
weight: 2,
fillOpacity: 0.95
});
if (losActivePeak.marker.closeTooltip) {
losActivePeak.marker.closeTooltip();
}
losActivePeak = null;
}
return null;
}
if (losActivePeak && losActivePeak !== best.item) {
losActivePeak.marker.setStyle({
radius: 4,
color: '#f59e0b',
fillColor: '#fbbf24',
weight: 2,
fillOpacity: 0.95
});
if (losActivePeak.marker.closeTooltip) {
losActivePeak.marker.closeTooltip();
}
}
if (!losActivePeak || losActivePeak !== best.item) {
best.item.marker.setStyle({
radius: 6,
color: '#f97316',
fillColor: '#fbbf24',
weight: 2,
fillOpacity: 1
});
best.item.marker.openTooltip();
losActivePeak = best.item;
}
return `Peak ${best.item.index}`;
}
function updateLosMapHover(distanceMeters) {
const resolved = resolveLosProfileDistance(distanceMeters);
if (!losProfileMeta || !resolved) return;
const [start, end] = resolved.endpoints;
const total = haversineMeters(start.lat, start.lng, end.lat, end.lng);
const clamped = Math.min(Math.max(resolved.localDistance, 0), total);
const t = total > 0 ? (clamped / total) : 0;
const lat = start.lat + (end.lat - start.lat) * t;
const lon = start.lng + (end.lng - start.lng) * t;
const label = resolved.segmentIndex === losActiveSegmentIndex ? updateLosPeakHighlight(clamped) : null;
const tooltip = label
? `${label}${formatDistanceMeters(clamped)}`
: `LOS ${getLosSegmentLabel(resolved.segmentIndex)}${formatDistanceMeters(clamped)}`;
if (!losHoverMarker) {
losHoverMarker = L.circleMarker([lat, lon], {
radius: 5,
color: '#e2e8f0',
fillColor: '#0f172a',
fillOpacity: 0.9,
weight: 2,
bubblingMouseEvents: false
}).addTo(losLayer);
} else {
losHoverMarker.setLatLng([lat, lon]);
}
losHoverMarker.bindTooltip(tooltip, { direction: 'top', opacity: 0.9, offset: [0, -8] });
losHoverMarker.openTooltip();
}
function updateLosProfileHover(ev) {
if (!losProfileMeta || !losProfileSvg || !losProfileTooltip) return;
if (!losProfileData || losProfileData.length < 2) return;
const rect = losProfileSvg.getBoundingClientRect();
const x = Math.min(Math.max(ev.clientX - rect.left, losProfileMeta.pad), rect.width - losProfileMeta.pad);
const ratio = (x - losProfileMeta.pad) / Math.max(1, rect.width - losProfileMeta.pad * 2);
const idx = Math.min(losProfileData.length - 1, Math.max(0, Math.round(ratio * (losProfileData.length - 1))));
const point = losProfileData[idx];
if (!point) return;
updateLosProfileCursor(point[0], point[1], point[2]);
updateLosMapHover(point[0]);
}
function losProfileDistanceFromEvent(ev) {
if (!losProfileMeta || !losProfileSvg) return null;
const rect = losProfileSvg.getBoundingClientRect();
const x = Math.min(Math.max(ev.clientX - rect.left, losProfileMeta.pad), rect.width - losProfileMeta.pad);
const ratio = (x - losProfileMeta.pad) / Math.max(1, rect.width - losProfileMeta.pad * 2);
const total = losProfileMeta.totalDistance;
return Math.min(Math.max(ratio, 0), 1) * total;
}
function copyLosCoords(distanceMeters) {
const resolved = resolveLosProfileDistance(distanceMeters);
if (!resolved || distanceMeters == null || !losProfileMeta) return;
const [start, end] = resolved.endpoints;
const total = haversineMeters(start.lat, start.lng, end.lat, end.lng);
const t = total > 0 ? Math.min(Math.max(resolved.localDistance / total, 0), 1) : 0;
const lat = start.lat + (end.lat - start.lat) * t;
const lon = start.lng + (end.lng - start.lng) * t;
const coords = `${lat.toFixed(5)}, ${lon.toFixed(5)}`;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(coords).then(() => {
setLosStatus(`LOS: coords copied ${coords}`);
}).catch(() => {
setLosStatus(`LOS: ${coords}`);
});
} else {
setLosStatus(`LOS: ${coords}`);
}
updateLosMapHover(distanceMeters);
}
function updateLosProfileAtDistance(distanceMeters) {
if (!losProfileData || losProfileData.length < 2 || !losProfileMeta) return;
const total = losProfileMeta.totalDistance;
const clamped = Math.min(Math.max(distanceMeters, 0), total);
lastLosDistance = clamped;
const idx = Math.min(losProfileData.length - 1, Math.max(0, Math.round((clamped / total) * (losProfileData.length - 1))));
const point = losProfileData[idx];
if (!point) return;
updateLosProfileCursor(point[0], point[1], point[2]);
updateLosMapHover(point[0]);
}
function clearLosProfileHover() {
const cursor = document.getElementById('los-profile-cursor');
const dot = document.getElementById('los-profile-point');
if (cursor) cursor.setAttribute('opacity', '0');
if (dot) dot.setAttribute('opacity', '0');
if (losProfileTooltip) losProfileTooltip.hidden = true;
clearLosHoverMarker();
}
function updateLosProfileFromMap(latlng) {
const endpoints = getActiveLosSegmentEndpoints();
if (!latlng || !endpoints) return;
if (!losProfileMeta || !losProfileData || losProfileData.length < 2) return;
const [start, end] = endpoints;
const dLat = end.lat - start.lat;
const dLon = end.lng - start.lng;
const denom = (dLat * dLat) + (dLon * dLon);
if (denom === 0) return;
let t = ((latlng.lat - start.lat) * dLat + (latlng.lng - start.lng) * dLon) / denom;
t = Math.min(Math.max(t, 0), 1);
const totalDistance = haversineMeters(start.lat, start.lng, end.lat, end.lng);
const ranges = Array.isArray(losProfileMeta.segmentRanges) ? losProfileMeta.segmentRanges : [];
const startOffset = ranges[losActiveSegmentIndex]?.startDistance || 0;
updateLosProfileAtDistance(startOffset + (totalDistance * t));
}
function renderLosPeaks(peaks) {
clearLosPeaks();
if (!Array.isArray(peaks) || peaks.length === 0) return;
peaks.forEach((peak, idx) => {
const lat = Number(peak.lat);
const lon = Number(peak.lon);
if (Number.isNaN(lat) || Number.isNaN(lon)) return;
const index = peak.index || (idx + 1);
const distanceMeters = peak.distance_m != null ? Number(peak.distance_m) : null;
const distance = formatDistanceMeters(distanceMeters);
const elev = peak.elevation_m != null ? `${peak.elevation_m} m` : '';
const coord = `${lat.toFixed(5)}, ${lon.toFixed(5)}`;
const tooltip = `Peak ${index}${distance ? `${distance}` : ''}<br/>${coord}${elev ? `<br/>${elev}` : ''}`;
const marker = L.circleMarker([lat, lon], {
radius: 4,
color: '#f59e0b',
fillColor: '#fbbf24',
fillOpacity: 0.95,
weight: 2,
bubblingMouseEvents: false
}).addTo(losLayer);
marker.bindTooltip(tooltip, { direction: 'top', opacity: 0.9 });
marker.on('click', (ev) => {
if (ev && ev.originalEvent) {
ev.originalEvent.preventDefault();
ev.originalEvent.stopPropagation();
}
const coords = `${lat.toFixed(5)}, ${lon.toFixed(5)}`;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(coords).then(() => {
setLosStatus(`LOS: Peak ${index} coords copied`);
}).catch(() => {
setLosStatus(`LOS: Peak ${index} ${coords}`);
});
} else {
setLosStatus(`LOS: Peak ${index} ${coords}`);
}
});
losPeakMarkers.push({
marker,
index,
distance: Number.isNaN(distanceMeters) ? null : distanceMeters,
lat,
lon
});
});
}
function applyLosResult(data) {
if (!data || !data.ok) return false;
const activeIndex = losActiveSegmentIndex;
const activeLine = getActiveLosLine();
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;
}
}
const blocked = data.blocked;
const meters = data.distance_m != null ? Number(data.distance_m) : null;
const segmentMeta = {
segment_index: activeIndex,
segment_label: getLosSegmentLabel(activeIndex),
distance_m: Number.isFinite(meters) ? meters : null,
blocked,
obstruction_m: data.max_obstruction_m,
suggested: false,
suggested_clear: false,
profile: Array.isArray(data.profile) ? data.profile : []
};
if (losSuggestion) {
losLayer.removeLayer(losSuggestion);
losSuggestion = null;
}
if (blocked) {
renderLosPeaks(data.peaks);
} else {
clearLosPeaks();
}
if (data.suggested) {
const s = data.suggested;
const label = s.clear ? 'Relay (Clear)' : 'Relay (Still Blocked)';
const color = s.clear ? '#22c55e' : '#f59e0b';
losSuggestion = L.circleMarker([s.lat, s.lon], {
radius: 6,
color,
fillColor: color,
fillOpacity: 0.9,
weight: 2
}).addTo(losLayer);
losSuggestion.bindTooltip(`${label}<br/>${s.lat}, ${s.lon}`, { direction: 'top' });
segmentMeta.suggested = true;
segmentMeta.suggested_clear = !!s.clear;
}
if (Number.isInteger(activeIndex) && activeIndex >= 0) {
losSegmentMeta[activeIndex] = { ...segmentMeta };
}
lastLosStatusMeta = segmentMeta;
setLosStatus(buildLosStatus(segmentMeta));
const combined = buildCombinedLosProfile();
renderLosProfile(combined.profile, combined.blocked, combined);
updateLosSegmentStyles();
if (activeLine) {
activeLine.bringToFront();
}
clearLosProfileHover();
return true;
}
async function runLosCheckClient(a, b, options = {}) {
const startIndex = Number.isInteger(losActiveSegmentIndex) ? losActiveSegmentIndex : 0;
const endIndex = startIndex + 1;
const heightA = getLosPointHeight(startIndex);
const heightB = getLosPointHeight(endIndex);
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 = losEffectiveElevations(points, elevations, distanceMeters);
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(adjusted[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 startIndex = Number.isInteger(losActiveSegmentIndex) ? losActiveSegmentIndex : 0;
const endIndex = startIndex + 1;
const heightA = getLosPointHeight(startIndex);
const heightB = getLosPointHeight(endIndex);
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);
propagationRaster = null;
}
propagationRasterCanvas = null;
propagationRasterMeta = null;
if (propagationRenderInFlight) {
propagationComputeToken += 1;
propagationRenderInFlight = false;
}
}
function clearPropagation() {
clearPropagationOrigins();
resetPropagationRaster();
propagationBaseRange = null;
propagationNeedsRender = false;
propagationRenderInFlight = false;
propagationComputeToken += 1;
setPropRange('');
setPropCost('');
setPropStatus('');
}
function setPropActive(active) {
propagationActive = active;
const btn = document.getElementById('prop-toggle');
if (btn) {
btn.classList.toggle('active', active);
btn.textContent = active ? 'Propagation: select node(s)' : 'Propagation';
}
if (propPanel) {
propPanel.classList.toggle('active', active);
}
setPropPanelCollapsed(propPanelCollapsed);
if (!active) {
clearPropagation();
} else {
setPropStatus('Select a node or click the map to set a transmitter.');
}
layoutSidePanels();
}
function keepOverlaysAbovePropagation() {
if (heatLayer && heatLayer.bringToFront) heatLayer.bringToFront();
if (historyLayer && historyLayer.bringToFront) historyLayer.bringToFront();
if (routeLayer && routeLayer.bringToFront) routeLayer.bringToFront();
if (trailLayer && trailLayer.bringToFront) trailLayer.bringToFront();
if (markerLayer && markerLayer.bringToFront) markerLayer.bringToFront();
if (losLayer && losLayer.bringToFront) losLayer.bringToFront();
}
function calcReceiverSensitivityDbm(bwHz, noiseFigureDb, snrMinDb) {
return -174 + (10 * Math.log10(bwHz)) + noiseFigureDb + snrMinDb;
}
function calcFsplAt1mDb(freqMHz) {
return 32.44 + (20 * Math.log10(freqMHz)) - 60;
}
function calcMaxPathLossDb(txPowerDbm, sensitivityDbm, fadeMarginDb) {
return txPowerDbm - sensitivityDbm - fadeMarginDb;
}
function calcRangeMeters(maxPathLossDb, freqMHz, pathLossExponent, clutterLossDb) {
const n = Math.max(1.5, pathLossExponent);
const fspl1m = calcFsplAt1mDb(freqMHz);
const lossBudget = maxPathLossDb - fspl1m - (Number.isFinite(clutterLossDb) ? clutterLossDb : 0);
const exponent = lossBudget / (10 * n);
return Math.max(1, Math.pow(10, exponent));
}
function formatDistance(meters) {
if (!Number.isFinite(meters)) return 'unknown';
return formatDistanceUnits(meters);
}
function haversineMeters(lat1, lon1, lat2, lon2) {
const toRad = (deg) => deg * (Math.PI / 180);
const r = 6371000;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a = Math.sin(dLat / 2) ** 2
+ Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return r * c;
}
function parseOptionalNumber(value) {
if (value == null) return null;
const trimmed = String(value).trim();
if (trimmed === '') return null;
const num = Number(trimmed);
return Number.isFinite(num) ? num : null;
}
function formatNumber(value) {
if (!Number.isFinite(value)) return '0';
return value.toLocaleString(undefined, { maximumFractionDigits: 0 });
}
function isMultiOriginEnabled() {
const input = document.getElementById('prop-multi-origin');
return input ? input.checked : false;
}
function getPropagationConfig() {
const txInput = document.getElementById('prop-txpower');
const txGainInput = document.getElementById('prop-tx-gain');
const opacityInput = document.getElementById('prop-opacity');
const modelSelect = document.getElementById('prop-model');
const terrainInput = document.getElementById('prop-terrain');
const txAglInput = document.getElementById('prop-tx-agl');
const rxAglInput = document.getElementById('prop-rx-agl');
const txMslInput = document.getElementById('prop-tx-msl');
const rxMslInput = document.getElementById('prop-rx-msl');
const minRxInput = document.getElementById('prop-min-rx');
const autoRangeInput = document.getElementById('prop-auto-range');
const fadeMarginInput = document.getElementById('prop-fade-margin');
const webGpuInput = document.getElementById('prop-webgpu');
const autoResInput = document.getElementById('prop-auto-res');
const maxCellsInput = document.getElementById('prop-max-cells');
const gridInput = document.getElementById('prop-grid');
const sampleInput = document.getElementById('prop-sample');
const rangeFactorInput = document.getElementById('prop-range-factor');
if (!txInput || !txGainInput || !opacityInput || !modelSelect || !terrainInput || !txAglInput || !rxAglInput || !txMslInput || !rxMslInput || !minRxInput || !autoRangeInput || !fadeMarginInput || !webGpuInput || !autoResInput || !maxCellsInput || !gridInput || !sampleInput || !rangeFactorInput) {
return null;
}
const txPower = Number(txInput.value);
const txAntennaGainDb = Number(txGainInput.value);
const opacity = Number(opacityInput.value);
const model = PROP_MODELS[modelSelect.value] || PROP_MODELS.suburban;
const terrain = terrainInput.checked;
const txAgl = Number(txAglInput.value);
const rxAgl = Number(rxAglInput.value);
const txMsl = parseOptionalNumber(txMslInput.value);
const rxMsl = parseOptionalNumber(rxMslInput.value);
const minRxDbm = Number(minRxInput.value);
const autoRange = autoRangeInput.checked;
const fadeMargin = fadeMarginInput.checked;
const useWebGpu = webGpuInput.checked && !webGpuInput.disabled;
const autoResolution = autoResInput.checked;
const maxCells = Number(maxCellsInput.value);
const gridStep = Number(gridInput.value);
const sampleStep = Number(sampleInput.value);
const rangeFactor = Number(rangeFactorInput.value);
if (!Number.isFinite(txPower) || !Number.isFinite(opacity)) return null;
return {
txPower,
txAntennaGainDb: Number.isFinite(txAntennaGainDb) ? Math.min(20, Math.max(-10, txAntennaGainDb)) : PROP_DEFAULTS.txAntennaGainDb,
opacity: Math.min(0.9, Math.max(0.05, opacity)),
model,
terrain,
gridStep: Number.isFinite(gridStep) ? Math.max(30, gridStep) : 90,
sampleStep: Number.isFinite(sampleStep) ? Math.max(30, sampleStep) : 90,
rangeFactor: Number.isFinite(rangeFactor) ? Math.min(1, Math.max(0.25, rangeFactor)) : 1,
txAgl: Number.isFinite(txAgl) ? Math.max(0, txAgl) : 0,
rxAgl: Number.isFinite(rxAgl) ? Math.max(0, rxAgl) : 0,
minRxDbm: Number.isFinite(minRxDbm) ? Math.min(-60, Math.max(-150, minRxDbm)) : -97,
autoRange,
fadeMargin,
useWebGpu,
autoResolution,
maxCells: Number.isFinite(maxCells) ? Math.min(500000, Math.max(20000, maxCells)) : 120000,
txMsl,
rxMsl
};
}
function estimatePropagationCost(renderRange, gridStep, sampleStep, originCount) {
const latScale = 111320;
const refLat = propagationOrigins.length
? (propagationOrigins.reduce((sum, origin) => sum + origin.lat, 0) / propagationOrigins.length)
: map.getCenter().lat;
const lonScale = 111320 * Math.cos(refLat * (Math.PI / 180));
const rows = Math.max(1, Math.ceil((renderRange * 2) / gridStep));
const cols = Math.max(1, Math.ceil((renderRange * 2) / (gridStep * (lonScale / latScale))));
const cells = rows * cols;
const avgSamples = Math.max(2, Math.ceil(renderRange / sampleStep) + 1);
const multiplier = Math.max(1, originCount || 1);
return {
cells,
samples: Math.round(cells * avgSamples * multiplier)
};
}
function derivePropagationResolution(config, renderRange, originCount) {
const originFactor = Math.max(1, originCount || 1);
let gridStep = config.gridStep;
let sampleStep = config.sampleStep;
let estimate = estimatePropagationCost(renderRange, gridStep, sampleStep, originFactor);
if (config.autoResolution && estimate.cells > (config.maxCells / originFactor)) {
const scale = Math.sqrt(estimate.cells / (config.maxCells / originFactor));
gridStep = Math.min(600, Math.max(30, Math.round((gridStep * scale) / 5) * 5));
sampleStep = Math.min(600, Math.max(30, Math.round((sampleStep * scale) / 5) * 5));
estimate = estimatePropagationCost(renderRange, gridStep, sampleStep, originFactor);
}
return {
gridStep,
sampleStep,
cells: estimate.cells,
samples: estimate.samples
};
}
function getPropagationOriginKey(origin) {
return origin.id || origin.key;
}
function clearPropagationOrigins() {
propagationOrigins = [];
propagationOriginMarkers.forEach((marker) => {
propagationLayer.removeLayer(marker);
});
propagationOriginMarkers.clear();
}
function removePropagationOrigin(origin) {
if (!origin) return;
const key = getPropagationOriginKey(origin);
if (key && propagationOriginMarkers.has(key)) {
propagationLayer.removeLayer(propagationOriginMarkers.get(key));
propagationOriginMarkers.delete(key);
}
propagationOrigins = propagationOrigins.filter((item) => getPropagationOriginKey(item) !== key);
updatePropagationSummary();
if (!propagationOrigins.length) {
setPropStatus('Select a node or click the map to set a transmitter.');
} else {
markPropagationDirty('Origin removed. Click "Render prop" to update.');
}
if (propagationRasterMeta && !propagationNeedsRender) {
updatePropagationStatusFromRaster();
}
}
function upsertPropagationOriginMarker(origin) {
const key = getPropagationOriginKey(origin);
if (!key) return;
if (!propagationOriginMarkers.has(key)) {
const marker = L.circleMarker([origin.lat, origin.lon], {
radius: 5,
color: '#22c55e',
fillColor: '#22c55e',
fillOpacity: 0.95,
weight: 2
}).addTo(propagationLayer);
marker.on('click', (ev) => {
if (ev && ev.originalEvent) {
ev.originalEvent.preventDefault();
ev.originalEvent.stopPropagation();
}
L.DomEvent.stop(ev);
removePropagationOrigin(origin);
});
propagationOriginMarkers.set(key, marker);
} else {
const marker = propagationOriginMarkers.get(key);
marker.setLatLng([origin.lat, origin.lon]);
}
}
function isNodeCoveredByRaster(lat, lon, meta) {
if (!meta) return false;
if (!meta.coverage) return false;
if (lat < meta.latMin || lat > meta.latMax || lon < meta.lonMin || lon > meta.lonMax) {
return false;
}
const row = Math.floor((meta.latMax - lat) / meta.latStep);
const col = Math.floor((lon - meta.lonMin) / meta.lonStep);
if (row < 0 || col < 0 || row >= meta.rows || col >= meta.cols) return false;
return meta.coverage[(row * meta.cols) + col] > 0;
}
function listLikelyNodesFromRaster() {
const matches = [];
if (!propagationOrigins.length) return matches;
deviceMeta.forEach((meta, id) => {
if (!meta || meta.lat == null || meta.lon == null) return;
if (propagationOrigins.some((origin) => origin.id === id)) return;
if (propagationRasterMeta && isNodeCoveredByRaster(meta.lat, meta.lon, propagationRasterMeta)) {
let dist = Infinity;
propagationOrigins.forEach((origin) => {
const candidate = haversineMeters(origin.lat, origin.lon, meta.lat, meta.lon);
if (candidate < dist) dist = candidate;
});
const label = meta.name ? meta.name : `${id.slice(0, 8)}...`;
matches.push({ id, label, dist });
}
});
matches.sort((a, b) => a.dist - b.dist);
return matches;
}
function updatePropagationStatusFromRaster() {
const likely = listLikelyNodesFromRaster();
const count = likely.length;
let status = `Likely to hit ${count} node${count === 1 ? '' : 's'}`;
if (count > 0) {
const names = likely.slice(0, 5).map(item => item.label).join(', ');
status += `: ${names}`;
if (count > 5) status += ` +${count - 5} more`;
}
setPropStatus(status);
}
function updatePropagationSummary() {
const config = getPropagationConfig();
if (!config) return;
const sensitivity = calcReceiverSensitivityDbm(
PROP_DEFAULTS.bwHz,
PROP_DEFAULTS.noiseFigureDb,
PROP_DEFAULTS.snrMinDb
);
const effectiveTxPower = config.txPower + config.txAntennaGainDb;
const maxPathLoss = config.autoRange
? (effectiveTxPower - config.minRxDbm)
: calcMaxPathLossDb(effectiveTxPower, sensitivity, PROP_DEFAULTS.fadeMarginDb);
const baseRange = calcRangeMeters(maxPathLoss, PROP_DEFAULTS.freqMHz, config.model.n, config.model.clutterLossDb);
propagationBaseRange = baseRange;
const renderRange = config.autoRange ? baseRange : (baseRange * config.rangeFactor);
const clutterNote = config.model.clutterLossDb ? ` +${config.model.clutterLossDb} dB clutter` : '';
const cutoffNote = config.autoRange ? ` • cutoff ${config.minRxDbm} dBm` : '';
setPropRange(`Range: ${formatDistance(renderRange)} (base ${formatDistance(baseRange)}${config.model.label}${clutterNote}${cutoffNote})`);
const originCount = propagationOrigins.length;
const resolution = derivePropagationResolution(config, renderRange, Math.max(1, originCount));
const resLabel = config.autoResolution ? 'auto ' : '';
const originLabel = originCount > 1 ? `${originCount} origins` : (originCount === 1 ? ' • 1 origin' : '');
setPropCost(`Estimate: ${formatNumber(resolution.cells)} cells • ${formatNumber(resolution.samples)} samples (${resLabel}grid ${formatNumber(resolution.gridStep)}m • sample ${formatNumber(resolution.sampleStep)}m${originLabel})`);
}
function markPropagationDirty(message) {
propagationNeedsRender = true;
if (message) {
setPropStatus(message);
} else if (propagationActive && propagationOrigins.length) {
setPropStatus('Settings changed. Click "Render prop" to update.');
}
}
function ensurePropagationWorker() {
if (propagationWorker) return propagationWorker;
const workerCode = [
`const TILE_URL = ${JSON.stringify(PROP_TERRARIUM_URL)};`,
'const TILE_SIZE = 256;',
'const tileCache = new Map();',
'function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); }',
'function lonLatToTile(lon, lat, zoom) {',
' const latRad = lat * Math.PI / 180;',
' const n = Math.pow(2, zoom);',
' const x = n * ((lon + 180) / 360);',
' const y = n * (1 - (Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI)) / 2;',
' const tileX = Math.floor(x);',
' const tileY = Math.floor(y);',
' const px = Math.floor((x - tileX) * TILE_SIZE);',
' const py = Math.floor((y - tileY) * TILE_SIZE);',
' return { tileX, tileY, px: clamp(px, 0, TILE_SIZE - 1), py: clamp(py, 0, TILE_SIZE - 1) };',
'}',
'async function fetchTile(z, x, y) {',
' const n = Math.pow(2, z);',
' if (x < 0 || y < 0 || x >= n || y >= n) return null;',
' const key = `${z}/${x}/${y}`;',
' if (tileCache.has(key)) return tileCache.get(key);',
' const url = TILE_URL.replace("{z}", z).replace("{x}", x).replace("{y}", y);',
' try {',
' const res = await fetch(url);',
' if (!res.ok) { tileCache.set(key, null); return null; }',
' const blob = await res.blob();',
' const bitmap = await createImageBitmap(blob);',
' const canvas = new OffscreenCanvas(TILE_SIZE, TILE_SIZE);',
' const ctx = canvas.getContext("2d");',
' ctx.drawImage(bitmap, 0, 0);',
' const data = ctx.getImageData(0, 0, TILE_SIZE, TILE_SIZE).data;',
' tileCache.set(key, data);',
' return data;',
' } catch (err) {',
' tileCache.set(key, null);',
' return null;',
' }',
'}',
'async function getElevation(lat, lon, zoom) {',
' const coords = lonLatToTile(lon, lat, zoom);',
' const tile = await fetchTile(zoom, coords.tileX, coords.tileY);',
' if (!tile) return null;',
' const idx = (coords.py * TILE_SIZE + coords.px) * 4;',
' const r = tile[idx];',
' const g = tile[idx + 1];',
' const b = tile[idx + 2];',
' return (r * 256 + g + b / 256) - 32768;',
'}',
'function knifeEdgeLossFromV(v) {',
' if (!Number.isFinite(v) || v <= 0) return 0;',
' const loss = 6.9 + 20 * Math.log10(Math.sqrt((v - 0.1) ** 2 + 1) + v - 0.1);',
' return Math.max(0, loss);',
'}',
'self.onmessage = async (event) => {',
' const data = event.data;',
' if (!data || data.type !== "render") return;',
' const { token, origins, config, renderRange, maxPathLossDb, fspl1mDb, freqMHz } = data;',
' try {',
' if (!Array.isArray(origins) || origins.length === 0) {',
' throw new Error("No origins provided");',
' }',
' const latScale = 111320;',
' const refLat = origins.reduce((sum, o) => sum + o.lat, 0) / origins.length;',
' const lonScale = 111320 * Math.cos(refLat * Math.PI / 180);',
' const latStep = config.gridStep / latScale;',
' const lonStep = config.gridStep / lonScale;',
' let latMin = Infinity;',
' let latMax = -Infinity;',
' let lonMin = Infinity;',
' let lonMax = -Infinity;',
' for (const origin of origins) {',
' const originLonScale = 111320 * Math.cos(origin.lat * Math.PI / 180);',
' const latRadius = renderRange / latScale;',
' const lonRadius = renderRange / originLonScale;',
' latMin = Math.min(latMin, origin.lat - latRadius);',
' latMax = Math.max(latMax, origin.lat + latRadius);',
' lonMin = Math.min(lonMin, origin.lon - lonRadius);',
' lonMax = Math.max(lonMax, origin.lon + lonRadius);',
' }',
' const rows = Math.max(1, Math.ceil((latMax - latMin) / latStep));',
' const cols = Math.max(1, Math.ceil((lonMax - lonMin) / lonStep));',
' const zoom = clamp(Math.round(Math.log2(156543.03392 * Math.cos(refLat * Math.PI / 180) / config.gridStep)), 8, 12);',
' const lambda = 299792458 / (freqMHz * 1e6);',
' const clutterLossDb = Number.isFinite(config.clutterLossDb) ? config.clutterLossDb : 0;',
' const originEntries = [];',
' for (const origin of origins) {',
' const originGround = config.useTerrain && config.txMsl == null ? await getElevation(origin.lat, origin.lon, zoom) : 0;',
' const txAbs = (config.txMsl != null) ? config.txMsl : (originGround ?? 0) + config.txAgl;',
' originEntries.push({',
' lat: origin.lat,',
' lon: origin.lon,',
' lonScale: 111320 * Math.cos(origin.lat * Math.PI / 180),',
' txAbs',
' });',
' }',
' const pixels = new Uint8ClampedArray(rows * cols * 4);',
' const coverage = new Uint8Array(rows * cols);',
' for (let row = 0; row < rows; row++) {',
' const lat = latMax - row * latStep;',
' for (let col = 0; col < cols; col++) {',
' const lon = lonMin + col * lonStep;',
' const idx = row * cols + col;',
' const offset = idx * 4;',
' let bestMargin = -Infinity;',
' let coverCount = 0;',
' let endGround = null;',
' if (config.useTerrain) {',
' endGround = await getElevation(lat, lon, zoom);',
' if (endGround == null) {',
' pixels[offset + 3] = 0;',
' continue;',
' }',
' }',
' for (const origin of originEntries) {',
' const dx = (lon - origin.lon) * origin.lonScale;',
' const dy = (lat - origin.lat) * latScale;',
' const distance = Math.sqrt(dx * dx + dy * dy);',
' if (distance <= 1 || distance > renderRange) {',
' continue;',
' }',
' let rxAbs = (config.rxMsl != null) ? config.rxMsl : (config.useTerrain ? endGround + config.rxAgl : origin.txAbs);',
' let maxV = 0;',
' let minClearanceRatio = Infinity;',
' if (config.useTerrain) {',
' const samples = Math.max(2, Math.ceil(distance / config.sampleStep) + 1);',
' for (let i = 1; i < samples - 1; i++) {',
' const t = i / (samples - 1);',
' const sLat = origin.lat + (lat - origin.lat) * t;',
' const sLon = origin.lon + (lon - origin.lon) * t;',
' const elev = await getElevation(sLat, sLon, zoom);',
' if (elev == null) continue;',
' const lineElev = origin.txAbs + (rxAbs - origin.txAbs) * t;',
' const d1 = distance * t;',
' const d2 = distance * (1 - t);',
' const f1 = Math.sqrt((lambda * d1 * d2) / (d1 + d2));',
' const bulge = (d1 * d2) / (2 * config.earthRadiusM);',
' const effectiveElev = elev + bulge;',
' let fresnel = 0;',
' if (config.fresnelFactor > 0) {',
' fresnel = config.fresnelFactor * f1;',
' }',
' const clearance = lineElev - effectiveElev;',
' if (f1 > 0) {',
' const ratio = clearance / f1;',
' if (ratio < minClearanceRatio) minClearanceRatio = ratio;',
' }',
' const obstruction = (effectiveElev - lineElev) - fresnel;',
' if (obstruction <= 0) continue;',
' const v = obstruction * Math.sqrt((2 * (d1 + d2)) / (lambda * d1 * d2));',
' if (v > maxV) maxV = v;',
' }',
' }',
' const extraLoss = maxV > 0 ? knifeEdgeLossFromV(maxV) : 0;',
' let clearanceLoss = 0;',
' if (config.useTerrain && Number.isFinite(minClearanceRatio) && minClearanceRatio < config.clearanceRatio) {',
' const ratio = Math.max(-1, minClearanceRatio);',
' const deficit = config.clearanceRatio - ratio;',
' clearanceLoss = Math.min(config.clearanceLossDb, (deficit / config.clearanceRatio) * config.clearanceLossDb);',
' }',
' const pathLoss = fspl1mDb + (10 * config.pathLossExp * Math.log10(distance)) + extraLoss + clearanceLoss + clutterLossDb;',
' const margin = maxPathLossDb - pathLoss;',
' if (margin > 0) {',
' coverCount += 1;',
' if (margin > bestMargin) bestMargin = margin;',
' }',
' }',
' if (coverCount > 0) {',
' const requiredOverlap = originEntries.length >= 3 ? originEntries.length : 2;',
' const strength = Math.min(1, bestMargin / 20);',
' if (coverCount >= requiredOverlap) {',
' pixels[offset] = 34;',
' pixels[offset + 1] = 197;',
' pixels[offset + 2] = 94;',
' } else {',
' pixels[offset] = 239;',
' pixels[offset + 1] = 68;',
' pixels[offset + 2] = 68;',
' }',
' pixels[offset + 3] = config.fadeByMargin ? Math.round(255 * strength) : 255;',
' coverage[idx] = Math.min(255, coverCount);',
' } else {',
' pixels[offset + 3] = 0;',
' }',
' }',
' if (row % 10 === 0) {',
' self.postMessage({ type: "progress", token, row, rows });',
' await new Promise((resolve) => setTimeout(resolve, 0));',
' }',
' }',
' self.postMessage({',
' type: "result",',
' token,',
' width: cols,',
' height: rows,',
' bounds: { latMin, latMax, lonMin, lonMax },',
' latStep,',
' lonStep,',
' pixels: pixels.buffer,',
' coverage: coverage.buffer',
' }, [pixels.buffer, coverage.buffer]);',
' } catch (err) {',
' const message = (err && err.message) ? err.message : String(err);',
' self.postMessage({ type: "error", token, error: message });',
' }',
'};'
].join('\n');
const blob = new Blob([workerCode], { type: 'text/javascript' });
propagationWorker = new Worker(URL.createObjectURL(blob));
propagationWorker.onmessage = (event) => {
const msg = event.data;
if (!msg || msg.token !== propagationComputeToken) return;
if (msg.type === 'progress') {
const pct = Math.round((msg.row / Math.max(1, msg.rows)) * 100);
setPropStatus(`Rendering: ${pct}%`);
return;
}
if (msg.type === 'result') {
const pixels = new Uint8ClampedArray(msg.pixels);
const coverage = new Uint8Array(msg.coverage);
const canvas = document.createElement('canvas');
canvas.width = msg.width;
canvas.height = msg.height;
const ctx = canvas.getContext('2d');
const imageData = new ImageData(pixels, msg.width, msg.height);
ctx.putImageData(imageData, 0, 0);
propagationRasterCanvas = canvas;
const dataUrl = canvas.toDataURL('image/png');
const bounds = [
[msg.bounds.latMin, msg.bounds.lonMin],
[msg.bounds.latMax, msg.bounds.lonMax]
];
if (!propagationRaster) {
propagationRaster = L.imageOverlay(dataUrl, bounds, { opacity: propagationLastConfig?.opacity ?? 0.2 }).addTo(propagationLayer);
} else {
propagationRaster.setUrl(dataUrl);
propagationRaster.setBounds(bounds);
}
if (propagationRaster && propagationLastConfig) {
propagationRaster.setOpacity(propagationLastConfig.opacity);
}
keepOverlaysAbovePropagation();
propagationRasterMeta = {
latMin: msg.bounds.latMin,
latMax: msg.bounds.latMax,
lonMin: msg.bounds.lonMin,
lonMax: msg.bounds.lonMax,
latStep: msg.latStep,
lonStep: msg.lonStep,
rows: msg.height,
cols: msg.width,
coverage
};
propagationRenderInFlight = false;
propagationNeedsRender = false;
if (propagationOrigins.length) {
updatePropagationStatusFromRaster();
}
return;
}
if (msg.type === 'error') {
setPropStatus(`Render failed: ${msg.error || 'unknown error'}`);
propagationRenderInFlight = false;
}
};
return propagationWorker;
}
async function ensurePropagationGpu() {
if (propagationGpu) return propagationGpu;
if (!navigator.gpu) return null;
if (propagationGpuInitPromise) return propagationGpuInitPromise;
propagationGpuInitPromise = (async () => {
try {
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) return null;
const device = await adapter.requestDevice();
const module = device.createShaderModule({
code: `
struct Params {
latMin: f32,
latMax: f32,
lonMin: f32,
lonMax: f32,
latStep: f32,
lonStep: f32,
latScale: f32,
renderRange: f32,
fspl1mDb: f32,
maxPathLossDb: f32,
pathLossExp: f32,
clutterLossDb: f32,
fadeByMargin: f32,
originCount: f32,
rows: f32,
cols: f32,
};
struct Origin {
lat: f32,
lon: f32,
lonScale: f32,
_pad: f32,
};
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var<storage, read> origins: array<Origin>;
@group(0) @binding(2) var<storage, read_write> outPixels: array<u32>;
@group(0) @binding(3) var<storage, read_write> outCoverage: array<u32>;
fn log10(x: f32) -> f32 {
return log2(x) / 3.321928;
}
@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let row = gid.y;
let col = gid.x;
let rows = u32(params.rows + 0.5);
let cols = u32(params.cols + 0.5);
if (row >= rows || col >= cols) {
return;
}
let lat = params.latMax - f32(row) * params.latStep;
let lon = params.lonMin + f32(col) * params.lonStep;
var bestMargin = -1e9;
var coverCount: u32 = 0u;
let originCount = u32(params.originCount + 0.5);
for (var i: u32 = 0u; i < originCount; i = i + 1u) {
let origin = origins[i];
let dx = (lon - origin.lon) * origin.lonScale;
let dy = (lat - origin.lat) * params.latScale;
let distance = sqrt(dx * dx + dy * dy);
if (distance <= 1.0 || distance > params.renderRange) {
continue;
}
let pathLoss = params.fspl1mDb + 10.0 * params.pathLossExp * log10(distance) + params.clutterLossDb;
let margin = params.maxPathLossDb - pathLoss;
if (margin > 0.0) {
coverCount = coverCount + 1u;
if (margin > bestMargin) {
bestMargin = margin;
}
}
}
let idx = (row * cols + col) * 4u;
if (coverCount > 0u) {
let strength = clamp(bestMargin / 20.0, 0.0, 1.0);
var required: u32 = 2u;
if (originCount >= 3u) {
required = originCount;
}
if (coverCount >= required) {
outPixels[idx] = 34u;
outPixels[idx + 1u] = 197u;
outPixels[idx + 2u] = 94u;
} else {
outPixels[idx] = 239u;
outPixels[idx + 1u] = 68u;
outPixels[idx + 2u] = 68u;
}
var alpha = 255.0;
if (params.fadeByMargin > 0.5) {
alpha = 255.0 * strength;
}
outPixels[idx + 3u] = u32(alpha);
outCoverage[row * cols + col] = coverCount;
} else {
outPixels[idx + 3u] = 0u;
outCoverage[row * cols + col] = 0u;
}
}
`
});
const pipeline = device.createComputePipeline({
layout: 'auto',
compute: { module, entryPoint: 'main' }
});
propagationGpu = { device, pipeline };
return propagationGpu;
} catch (err) {
propagationGpu = null;
return null;
} finally {
propagationGpuInitPromise = null;
}
})();
return propagationGpuInitPromise;
}
async function renderPropagationRasterWebGpu({
token,
origins,
config,
renderRange,
maxPathLossDb,
resolution
}) {
try {
const gpu = await ensurePropagationGpu();
if (!gpu) return false;
const { device, pipeline } = gpu;
const latScale = 111320;
const refLat = origins.reduce((sum, origin) => sum + origin.lat, 0) / origins.length;
const lonScale = 111320 * Math.cos(refLat * (Math.PI / 180));
const latStep = resolution.gridStep / latScale;
const lonStep = resolution.gridStep / lonScale;
let latMin = Infinity;
let latMax = -Infinity;
let lonMin = Infinity;
let lonMax = -Infinity;
origins.forEach((origin) => {
const originLonScale = 111320 * Math.cos(origin.lat * (Math.PI / 180));
const latRadius = renderRange / latScale;
const lonRadius = renderRange / originLonScale;
latMin = Math.min(latMin, origin.lat - latRadius);
latMax = Math.max(latMax, origin.lat + latRadius);
lonMin = Math.min(lonMin, origin.lon - lonRadius);
lonMax = Math.max(lonMax, origin.lon + lonRadius);
});
const rows = Math.max(1, Math.ceil((latMax - latMin) / latStep));
const cols = Math.max(1, Math.ceil((lonMax - lonMin) / lonStep));
const cellCount = rows * cols;
const params = new Float32Array([
latMin,
latMax,
lonMin,
lonMax,
latStep,
lonStep,
latScale,
renderRange,
calcFsplAt1mDb(PROP_DEFAULTS.freqMHz),
maxPathLossDb,
config.model.n,
config.model.clutterLossDb,
config.fadeMargin ? 1 : 0,
origins.length,
rows,
cols
]);
const originData = new Float32Array(origins.length * 4);
origins.forEach((origin, idx) => {
const offset = idx * 4;
originData[offset] = origin.lat;
originData[offset + 1] = origin.lon;
originData[offset + 2] = 111320 * Math.cos(origin.lat * (Math.PI / 180));
originData[offset + 3] = 0;
});
const paramsBuffer = device.createBuffer({
size: params.byteLength,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
});
device.queue.writeBuffer(paramsBuffer, 0, params);
const originsBuffer = device.createBuffer({
size: originData.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
});
device.queue.writeBuffer(originsBuffer, 0, originData);
const pixelBufferSize = cellCount * 4 * 4;
const coverageBufferSize = cellCount * 4;
const pixelBuffer = device.createBuffer({
size: pixelBufferSize,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC
});
const coverageBuffer = device.createBuffer({
size: coverageBufferSize,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC
});
const pixelReadBuffer = device.createBuffer({
size: pixelBufferSize,
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
});
const coverageReadBuffer = device.createBuffer({
size: coverageBufferSize,
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
});
const bindGroup = device.createBindGroup({
layout: pipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: paramsBuffer } },
{ binding: 1, resource: { buffer: originsBuffer } },
{ binding: 2, resource: { buffer: pixelBuffer } },
{ binding: 3, resource: { buffer: coverageBuffer } }
]
});
const encoder = device.createCommandEncoder();
const pass = encoder.beginComputePass();
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
pass.dispatchWorkgroups(Math.ceil(cols / 8), Math.ceil(rows / 8));
pass.end();
encoder.copyBufferToBuffer(pixelBuffer, 0, pixelReadBuffer, 0, pixelBufferSize);
encoder.copyBufferToBuffer(coverageBuffer, 0, coverageReadBuffer, 0, coverageBufferSize);
device.queue.submit([encoder.finish()]);
await device.queue.onSubmittedWorkDone();
if (token !== propagationComputeToken) return true;
await pixelReadBuffer.mapAsync(GPUMapMode.READ);
await coverageReadBuffer.mapAsync(GPUMapMode.READ);
const pixelCopy = pixelReadBuffer.getMappedRange();
const coverageCopy = coverageReadBuffer.getMappedRange();
const pixelU32 = new Uint32Array(pixelCopy);
const coverageU32 = new Uint32Array(coverageCopy);
const pixels = new Uint8ClampedArray(pixelU32.length);
for (let i = 0; i < pixelU32.length; i++) {
pixels[i] = pixelU32[i];
}
const coverage = new Uint8Array(coverageU32.length);
for (let i = 0; i < coverageU32.length; i++) {
coverage[i] = Math.min(255, coverageU32[i]);
}
pixelReadBuffer.unmap();
coverageReadBuffer.unmap();
if (token !== propagationComputeToken) return true;
const canvas = document.createElement('canvas');
canvas.width = cols;
canvas.height = rows;
const ctx = canvas.getContext('2d');
const imageData = new ImageData(pixels, cols, rows);
ctx.putImageData(imageData, 0, 0);
propagationRasterCanvas = canvas;
const dataUrl = canvas.toDataURL('image/png');
const bounds = [
[latMin, lonMin],
[latMax, lonMax]
];
if (!propagationRaster) {
propagationRaster = L.imageOverlay(dataUrl, bounds, { opacity: propagationLastConfig?.opacity ?? 0.2 }).addTo(propagationLayer);
} else {
propagationRaster.setUrl(dataUrl);
propagationRaster.setBounds(bounds);
}
if (propagationRaster && propagationLastConfig) {
propagationRaster.setOpacity(propagationLastConfig.opacity);
}
keepOverlaysAbovePropagation();
propagationRasterMeta = {
latMin,
latMax,
lonMin,
lonMax,
latStep,
lonStep,
rows,
cols,
coverage
};
propagationRenderInFlight = false;
propagationNeedsRender = false;
if (propagationOrigins.length) {
updatePropagationStatusFromRaster();
}
return true;
} catch (err) {
return false;
}
}
async function renderPropagationRaster() {
if (!propagationActive) return;
const config = getPropagationConfig();
if (!config) return;
if (!propagationOrigins.length) {
setPropStatus('Select a node or click the map to set a transmitter.');
return;
}
if (propagationRenderInFlight) {
setPropStatus('Render already in progress.');
return;
}
resetPropagationRaster();
const sensitivity = calcReceiverSensitivityDbm(
PROP_DEFAULTS.bwHz,
PROP_DEFAULTS.noiseFigureDb,
PROP_DEFAULTS.snrMinDb
);
const effectiveTxPower = config.txPower + config.txAntennaGainDb;
const maxPathLoss = config.autoRange
? (effectiveTxPower - config.minRxDbm)
: calcMaxPathLossDb(effectiveTxPower, sensitivity, PROP_DEFAULTS.fadeMarginDb);
const baseRange = calcRangeMeters(maxPathLoss, PROP_DEFAULTS.freqMHz, config.model.n, config.model.clutterLossDb);
propagationBaseRange = baseRange;
const renderRange = config.autoRange ? baseRange : (baseRange * config.rangeFactor);
const originCount = propagationOrigins.length;
const resolution = derivePropagationResolution(config, renderRange, originCount);
propagationLastConfig = config;
propagationOrigins.forEach((origin) => upsertPropagationOriginMarker(origin));
updatePropagationSummary();
propagationComputeToken += 1;
propagationRenderInFlight = true;
const token = propagationComputeToken;
const origins = propagationOrigins.map((origin) => ({ lat: origin.lat, lon: origin.lon }));
if (config.useWebGpu && !config.terrain) {
setPropStatus('Rendering (WebGPU): 0%');
const ok = await renderPropagationRasterWebGpu({
token,
origins,
config,
renderRange,
maxPathLossDb: maxPathLoss,
resolution
});
if (ok) return;
if (token === propagationComputeToken) {
setPropStatus('WebGPU unavailable. Falling back to CPU...');
}
} else if (config.useWebGpu && config.terrain) {
setPropStatus('WebGPU experimental only supports terrain off. Using CPU...');
} else {
setPropStatus('Rendering: 0%');
}
ensurePropagationWorker();
propagationWorker.postMessage({
type: 'render',
token,
origins,
renderRange,
maxPathLossDb: maxPathLoss,
fspl1mDb: calcFsplAt1mDb(PROP_DEFAULTS.freqMHz),
freqMHz: PROP_DEFAULTS.freqMHz,
config: {
gridStep: resolution.gridStep,
sampleStep: resolution.sampleStep,
pathLossExp: config.model.n,
clutterLossDb: config.model.clutterLossDb,
useTerrain: config.terrain,
fresnelFactor: PROP_DEFAULTS.fresnelFactor,
clearanceRatio: PROP_DEFAULTS.clearanceRatio,
clearanceLossDb: PROP_DEFAULTS.clearanceLossDb,
earthRadiusM: PROP_DEFAULTS.earthRadiusM,
txAgl: config.txAgl,
rxAgl: config.rxAgl,
txMsl: config.txMsl,
rxMsl: config.rxMsl,
fadeByMargin: config.fadeMargin
}
});
}
function setPropagationOrigin(latlng, id = null) {
if (!latlng) return;
const meta = id ? deviceMeta.get(id) : null;
const lat = latlng.lat;
const lon = latlng.lng ?? latlng.lon;
const multi = isMultiOriginEnabled();
if (!multi) {
clearPropagationOrigins();
}
let origin = null;
if (id) {
origin = propagationOrigins.find(item => item.id === id) || null;
}
if (!origin && multi && !id) {
origin = propagationOrigins.find(item => item.lat === lat && item.lon === lon) || null;
}
if (!origin) {
origin = {
lat,
lon,
id,
key: id ? null : `manual-${Date.now()}-${propagationOriginSeq += 1}`,
name: meta ? meta.name : null
};
propagationOrigins.push(origin);
} else {
origin.lat = lat;
origin.lon = lon;
origin.name = meta ? meta.name : origin.name;
}
upsertPropagationOriginMarker(origin);
updatePropagationSummary();
const label = propagationOrigins.length === 1 ? 'Origin set.' : `${propagationOrigins.length} origins set.`;
markPropagationDirty(`${label} Click "Render prop" to calculate coverage.`);
}
function formatLastContact(tsSeconds) {
if (!tsSeconds) return 'unknown';
const dt = new Date(tsSeconds * 1000);
return dt.toLocaleString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
hour12: true
});
}
function payloadTypeLabel(pt) {
const val = Number(pt);
if (val === 4) return 'Advert';
if (val === 8 || val === 9) return 'Trace';
if (val === 2 || val === 5) return 'Message';
return Number.isFinite(val) ? `Type ${val}` : 'Unknown';
}
function shortHash(hash) {
if (!hash) return 'unknown';
const text = String(hash);
return text.length > 10 ? `${text.slice(0, 10)}` : text;
}
function deviceLabelFromId(id) {
if (!id) return 'Unknown';
const d = deviceData.get(id);
if (d) return deviceDisplayName(d);
return `${String(id).slice(0, 8)}`;
}
function makeHistoryPopup(entry) {
const count = Number(entry && entry.count) || 1;
const lastSeen = entry && entry.lastTs ? formatLastContact(entry.lastTs) : 'unknown';
const rawSamples = entry && Array.isArray(entry.recent) ? entry.recent : [];
const samples = rawSamples.filter((sample) => (
sample && (
sample.message_hash || sample.origin_id || sample.receiver_id || sample.payload_type || sample.topic
)
)).slice(0, 3);
const sampleHtml = samples.length
? samples.map((sample) => {
const when = sample.ts ? formatLastContact(sample.ts) : 'unknown';
const label = payloadTypeLabel(sample.payload_type);
const origin = deviceLabelFromId(sample.origin_id);
const receiver = deviceLabelFromId(sample.receiver_id);
const routeMode = sample.route_mode ? String(sample.route_mode) : 'path';
return `
<div class="popup-sample">
<strong>${label}</strong> • ${when}<br/>
Origin: ${origin}<br/>
Receiver: ${receiver}<br/>
Route: ${routeMode}<br/>
Hash: ${shortHash(sample.message_hash)}
</div>
`;
}).join('')
: '<div class="popup-sample">No packet details yet.</div>';
return `
<span class="popup-title">History edge</span>
<span class="small">
Count: ${count}<br/>
Last Seen: ${lastSeen}<br/>
${sampleHtml}
</span>
`;
}
function haversineMeters(lat1, lon1, lat2, lon2) {
const R = 6371000;
const toRad = (deg) => (deg * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a = Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
Math.sin(dLon / 2) ** 2;
return 2 * R * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
function sampleLosPoints(lat1, lon1, lat2, lon2) {
const distance = haversineMeters(lat1, lon1, lat2, lon2);
if (distance <= 0) {
return [
{ lat: lat1, lon: lon1, t: 0 },
{ lat: lat2, lon: lon2, t: 1 }
];
}
let samples = Math.floor(distance / Math.max(1, losSampleStepMeters)) + 1;
samples = Math.max(losSampleMin, Math.min(losSampleMax, samples));
if (samples < 2) samples = 2;
const points = [];
for (let i = 0; i < samples; i += 1) {
const t = i / (samples - 1);
const lat = lat1 + (lat2 - lat1) * t;
const lon = lon1 + (lon2 - lon1) * t;
points.push({ lat, lon, t });
}
return points;
}
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;
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.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' };
}
if (payload.status && payload.status !== 'OK') {
return { ok: false, error: `elevation_fetch_failed:${payload.status}` };
}
const elevs = payload.results || [];
if (elevs.length !== chunk.length) {
return { ok: false, error: 'elevation_fetch_failed:unexpected_length' };
}
elevs.forEach((entry, idx) => {
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))) {
return { ok: false, error: 'elevation_fetch_failed:missing' };
}
return { ok: true, elevations: results };
}
function losMaxObstruction(points, elevations, startIdx, endIdx) {
if (endIdx <= startIdx + 1) return 0;
const startT = points[startIdx].t;
const endT = points[endIdx].t;
if (endT <= startT) return 0;
const startElev = elevations[startIdx];
const endElev = elevations[endIdx];
let maxObstruction = 0;
for (let idx = startIdx + 1; idx < endIdx; idx += 1) {
const frac = (points[idx].t - startT) / (endT - startT);
const lineElev = startElev + (endElev - startElev) * frac;
const clearance = elevations[idx] - lineElev;
if (clearance > maxObstruction) maxObstruction = clearance;
}
return maxObstruction;
}
function losEarthRadiusMeters() {
return 6371000 * losCurvatureFactor;
}
function losEarthBulgeMeters(distanceMeters, t) {
if (!losCurvatureEnabled || !(distanceMeters > 0)) return 0;
const d1 = distanceMeters * t;
const d2 = distanceMeters - d1;
const earthRadiusMeters = losEarthRadiusMeters();
if (!(earthRadiusMeters > 0)) return 0;
return (d1 * d2) / (2 * earthRadiusMeters);
}
function losEffectiveElevations(points, elevations, distanceMeters) {
return elevations.map((elev, idx) =>
elev + losEarthBulgeMeters(distanceMeters, points[idx]?.t || 0)
);
}
function findLosSuggestion(points, elevations) {
if (points.length < 3) return null;
let bestIdx = null;
let bestScore = null;
let bestClear = false;
for (let idx = 1; idx < points.length - 1; idx += 1) {
const obstA = losMaxObstruction(points, elevations, 0, idx);
const obstB = losMaxObstruction(points, elevations, idx, points.length - 1);
const score = Math.max(obstA, obstB);
const clear = score <= 0;
if (clear && !bestClear) {
bestIdx = idx;
bestScore = score;
bestClear = true;
} else if (clear && bestClear) {
if (elevations[idx] > elevations[bestIdx]) {
bestIdx = idx;
bestScore = score;
}
} else if (!bestClear) {
if (bestScore == null || score < bestScore) {
bestIdx = idx;
bestScore = score;
}
}
}
if (bestIdx == null) return null;
return {
lat: Number(points[bestIdx].lat.toFixed(6)),
lon: Number(points[bestIdx].lon.toFixed(6)),
elevation_m: Number(elevations[bestIdx].toFixed(2)),
clear: bestClear,
max_obstruction_m: bestScore != null ? Number(bestScore.toFixed(2)) : null
};
}
function findLosPeaks(points, elevations, distanceMeters) {
if (points.length < 3) return [];
const peakIndices = [];
for (let i = 1; i < elevations.length - 1; i += 1) {
const elev = elevations[i];
if (elev >= elevations[i - 1] && elev >= elevations[i + 1]) {
peakIndices.push(i);
}
}
if (peakIndices.length === 0) {
let maxIdx = 1;
for (let i = 2; i < elevations.length - 1; i += 1) {
if (elevations[i] > elevations[maxIdx]) maxIdx = i;
}
peakIndices.push(maxIdx);
}
const limited = peakIndices
.sort((a, b) => elevations[b] - elevations[a])
.slice(0, losPeaksMax)
.sort((a, b) => points[a].t - points[b].t);
return limited.map((idx, i) => ({
index: i + 1,
lat: Number(points[idx].lat.toFixed(6)),
lon: Number(points[idx].lon.toFixed(6)),
elevation_m: Number(elevations[idx].toFixed(2)),
distance_m: Number((distanceMeters * points[idx].t).toFixed(2))
}));
}
function makePopup(d) {
const lastContact = formatLastContact(getLastSeenTs(d));
const deviceLabel = deviceShortId(d);
const qrTitle = (d.name || '').trim() || deviceLabel;
const publicKey = typeof d.device_id === 'string' ? d.device_id : '';
const latText = Number(d.lat).toFixed(6);
const lonText = Number(d.lon).toFixed(6);
const locationText = `Location: ${latText}, ${lonText}`;
const publicKeyCopyTitle = 'Copy full public key';
const qrParams = new URLSearchParams({
name: qrTitle,
public_key: publicKey,
type: String(meshCoreContactTypeForDevice(d)),
box_size: '14',
border: '4'
});
const qrCodeUrl = publicKey ? withToken(`/qr?${qrParams.toString()}`) : '';
const popupId = publicKey
? `<button
type="button"
class="popup-id popup-copy-id popup-copy-trigger"
data-copy-text="${publicKey}"
title="${publicKeyCopyTitle}"
>${deviceLabel}</button>`
: `<span class="popup-id">${deviceLabel}</span>`;
const title = d.name
? `<span class="popup-title">${d.name}</span>${popupId}`
: `<span class="popup-title">${popupId}</span>`;
const role = resolveRole(d);
const roleLabel = role === 'unknown' ? '' : role.charAt(0).toUpperCase() + role.slice(1);
const mqttOnline = isMqttOnline(d);
const popupActions = [];
if (qrCodeButtonEnabled && qrCodeUrl) {
popupActions.push(`
<button
type="button"
class="popup-action-button"
data-qr-url="${qrCodeUrl}"
data-qr-title="${escapeHtmlAttr(qrTitle)}"
data-qr-key="${escapeHtmlAttr(publicKey)}"
>Generate QR Code</button>
`);
}
return `
${title}
<span class="small">
${roleLabel ? `Role: ${roleLabel}<br/>` : ``}
<button
type="button"
class="popup-copy-location popup-copy-trigger"
data-copy-text="${locationText}"
title="Copy location"
>${locationText}</button><br/>
Last Contact: ${lastContact}<br/>
${mqttOnline ? `MQTT: Online<br/>` : ``}
${d.rssi != null ? `RSSI: ${d.rssi}<br/>` : ``}
${d.snr != null ? `SNR: ${d.snr}<br/>` : ``}
${popupActions.length ? `<div class="popup-actions">${popupActions.join('')}</div>` : ``}
</span>
`;
}
async function copyPopupText(ev) {
const btn = ev?.currentTarget;
if (!btn) return;
const text = typeof btn.dataset.copyText === 'string' ? btn.dataset.copyText : '';
if (!text) return;
let copied = false;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
copied = true;
}
} catch (_err) {
copied = false;
}
const original = btn.textContent || text;
btn.textContent = copied ? 'Copied' : text;
window.setTimeout(() => {
btn.textContent = original;
}, 1200);
}
function closeQrModal() {
if (!qrModal) return;
qrModal.hidden = true;
if (qrModalImage) {
qrModalImage.removeAttribute('src');
}
if (qrModalTitle) {
qrModalTitle.textContent = 'Node';
}
if (qrModalLabel) {
qrModalLabel.textContent = '';
qrModalLabel.hidden = true;
delete qrModalLabel.dataset.copyText;
qrModalLabel.removeAttribute('title');
}
}
function openQrModal(url, title, key) {
if (!qrModal || !qrModalImage) return;
qrModalImage.src = url;
qrModalImage.alt = title ? `QR code for ${title}` : 'QR code';
if (qrModalTitle) {
qrModalTitle.textContent = title || 'Node';
}
if (qrModalLabel) {
qrModalLabel.textContent = key ? `${key.slice(0, 8)}` : '';
qrModalLabel.hidden = !key;
if (key) {
qrModalLabel.dataset.copyText = key;
qrModalLabel.title = 'Copy full public key';
} else {
delete qrModalLabel.dataset.copyText;
qrModalLabel.removeAttribute('title');
}
}
qrModal.hidden = false;
}
function handleQrPopupClick(ev) {
const btn = ev?.currentTarget;
const url = btn?.dataset?.qrUrl;
if (!url) return;
ev.preventDefault();
ev.stopPropagation();
openQrModal(
url,
btn.dataset.qrTitle || '',
btn.dataset.qrKey || ''
);
}
function upsertDevice(d, trail) {
const id = d.device_id;
const latlng = [d.lat, d.lon];
const visibleInViewport = latLngInViewport(d.lat, d.lon);
const role = resolveRole(d);
const style = markerStyleForDevice(d);
deviceData.set(id, d);
deviceMeta.set(id, { lat: d.lat, lon: d.lon, name: d.name });
// marker
if (!markers.has(id)) {
const m = L.circleMarker(latlng, { ...style, renderer: vectorRenderer });
m.bindPopup(makePopup(d), {
maxWidth: 260,
maxHeight: 320,
autoPan: false,
keepInView: false
});
m.on('popupopen', (ev) => {
const root = ev?.popup?.getElement?.();
if (!root) return;
root.querySelectorAll('.popup-copy-trigger').forEach((btn) => {
if (btn.dataset.boundClick === 'true') return;
btn.dataset.boundClick = 'true';
btn.addEventListener('click', copyPopupText);
});
root.querySelectorAll('.popup-action-button[data-qr-url]').forEach((btn) => {
if (btn.dataset.boundQr === 'true') return;
btn.dataset.boundQr = 'true';
btn.addEventListener('click', handleQrPopupClick);
});
});
m.__suppressClick = false;
m.__longPressTimer = null;
m.__longPressFired = false;
const triggerLosSelect = () => {
if (!losActive) {
return;
}
handleLosPoint(m.getLatLng());
m.closePopup();
};
const cancelLongPress = () => {
if (m.__longPressTimer) {
clearTimeout(m.__longPressTimer);
m.__longPressTimer = null;
}
};
m.on('click', (ev) => {
if (m.__suppressClick) {
m.__suppressClick = false;
return;
}
const original = ev.originalEvent;
if (original && original.shiftKey) {
triggerLosSelect();
original.preventDefault();
original.stopPropagation();
L.DomEvent.stop(ev);
return;
}
if (peersActive) {
selectPeerNode(id);
m.openPopup();
if (original) {
original.preventDefault();
original.stopPropagation();
}
L.DomEvent.stop(ev);
return;
}
if (propagationActive) {
setPropagationOrigin(m.getLatLng(), id);
m.openPopup();
if (original) {
original.preventDefault();
original.stopPropagation();
}
L.DomEvent.stop(ev);
}
});
m.on('contextmenu', (ev) => {
m.__suppressClick = true;
triggerLosSelect();
if (ev && ev.originalEvent) {
ev.originalEvent.preventDefault();
ev.originalEvent.stopPropagation();
}
L.DomEvent.stop(ev);
});
m.on('touchstart', (ev) => {
m.__longPressFired = false;
cancelLongPress();
m.__longPressTimer = setTimeout(() => {
m.__longPressFired = true;
m.__suppressClick = true;
triggerLosSelect();
}, 550);
if (ev && ev.originalEvent) {
ev.originalEvent.preventDefault();
ev.originalEvent.stopPropagation();
}
L.DomEvent.stop(ev);
});
m.on('touchmove', () => {
cancelLongPress();
});
m.on('touchend', (ev) => {
cancelLongPress();
if (m.__longPressFired) {
if (ev && ev.originalEvent) {
ev.originalEvent.preventDefault();
ev.originalEvent.stopPropagation();
}
L.DomEvent.stop(ev);
}
});
m.on('touchcancel', () => {
cancelLongPress();
m.__longPressFired = false;
});
markers.set(id, m);
updateMarkerLabel(m, d);
syncLayerMembership(markerLayer, m, nodesVisible && visibleInViewport);
} else {
const m = markers.get(id);
m.setLatLng(latlng);
m.setPopupContent(makePopup(d));
if (m.setStyle) m.setStyle(style);
updateMarkerLabel(m, d);
syncLayerMembership(markerLayer, m, nodesVisible && visibleInViewport);
}
// trail polyline (skip companions)
if (role !== 'companion' && Array.isArray(trail) && trail.length >= 2) {
const points = trail.map(p => [p[0], p[1]]);
if (!polylines.has(id)) {
const pl = L.polyline(points, {
renderer: animatedLineRenderer,
color: '#38bdf8',
weight: 3,
opacity: 0.85,
className: 'trail-animated'
});
polylines.set(id, pl);
syncLayerMembership(trailLayer, pl, nodesVisible && visibleInViewport);
} else {
const pl = polylines.get(id);
pl.setLatLngs(points);
if (pl.setStyle) {
pl.setStyle({ color: '#38bdf8', weight: 3, opacity: 0.85 });
}
syncLayerMembership(trailLayer, pl, nodesVisible && visibleInViewport);
}
} else if (polylines.has(id)) {
trailLayer.removeLayer(polylines.get(id));
polylines.get(id).__attached = false;
polylines.delete(id);
}
refreshStats();
if (propagationActive && propagationOrigins.length) {
const origin = propagationOrigins.find(item => item.id === id);
if (origin) {
origin.lat = d.lat;
origin.lon = d.lon;
origin.name = d.name || origin.name;
upsertPropagationOriginMarker(origin);
updatePropagationSummary();
markPropagationDirty('Origin moved. Click "Render prop" to update.');
return;
}
if (propagationRasterMeta && !propagationNeedsRender) {
updatePropagationStatusFromRaster();
}
}
}
function removeDevices(ids) {
ids.forEach(id => {
if (markers.has(id)) {
markerLayer.removeLayer(markers.get(id));
markers.delete(id);
}
if (deviceData.has(id)) {
deviceData.delete(id);
}
if (polylines.has(id)) {
trailLayer.removeLayer(polylines.get(id));
polylines.delete(id);
}
deviceMeta.delete(id);
});
refreshStats();
refreshOnlineMarkers();
if (propagationActive && propagationOrigins.length) {
const removed = propagationOrigins.filter(origin => origin.id && ids.includes(origin.id));
if (removed.length) {
removed.forEach((origin) => {
const key = getPropagationOriginKey(origin);
if (key && propagationOriginMarkers.has(key)) {
propagationLayer.removeLayer(propagationOriginMarkers.get(key));
propagationOriginMarkers.delete(key);
}
});
propagationOrigins = propagationOrigins.filter(origin => !(origin.id && ids.includes(origin.id)));
updatePropagationSummary();
if (!propagationOrigins.length) {
setPropStatus('Select a node or click the map to set a transmitter.');
} else {
markPropagationDirty('Origin removed. Click "Render prop" to update.');
}
} else {
if (propagationRasterMeta && !propagationNeedsRender) {
updatePropagationStatusFromRaster();
}
}
}
}
function refreshOnlineMarkers() {
markers.forEach((m, id) => {
const d = deviceData.get(id);
if (!d) return;
const style = markerStyleForDevice(d);
if (m.setStyle) m.setStyle(style);
if (m.getPopup()) m.setPopupContent(makePopup(d));
});
refreshStats();
}
function refreshViewportLayers() {
const shouldShowNodes = nodesVisible;
for (const [id, marker] of markers.entries()) {
const d = deviceData.get(id);
const visible = Boolean(d) && shouldShowNodes && latLngInViewport(d.lat, d.lon);
syncLayerMembership(markerLayer, marker, visible);
}
for (const [id, line] of polylines.entries()) {
const d = deviceData.get(id);
const visible = Boolean(d) && shouldShowNodes && latLngInViewport(d.lat, d.lon);
syncLayerMembership(trailLayer, line, visible);
}
if (coverageVisible && coverageData) {
if (coverageProvider === 'meshmapper' && meshMapperCoverageSource === coverageData && meshMapperCoverageRects.length) {
syncMeshMapperCoverageViewport();
} else {
renderCoverage(coverageData);
}
}
}
function removeRoutes(ids) {
ids.forEach(id => {
const entry = routeLines.get(id);
if (!entry) return;
if (entry.timeout) clearTimeout(entry.timeout);
removeArcadeMarker(id);
routeLayer.removeLayer(entry.line);
routeLines.delete(id);
// Cleanup hop markers
if (hopMarkers.has(id)) {
hopMarkers.get(id).forEach(m => hopLayer.removeLayer(m));
hopMarkers.delete(id);
}
});
refreshStats();
}
function clearRoutes() {
routeLines.forEach((entry, routeId) => {
if (entry.timeout) clearTimeout(entry.timeout);
removeArcadeMarker(routeId);
routeLayer.removeLayer(entry.line);
});
routeLines.clear();
stopArcadeLoop();
refreshStats();
}
function clearHistoryLayer() {
historyLines.forEach(entry => {
if (!entry || !entry.line) return;
historyLayer.removeLayer(entry.line);
});
historyLines.clear();
refreshHistoryStyles();
refreshStats();
}
function removeHistoryEdges(ids) {
ids.forEach(id => {
const entry = historyLines.get(id);
if (!entry) return;
historyLayer.removeLayer(entry.line);
historyLines.delete(id);
historyCache.delete(id);
});
refreshHistoryStyles();
refreshStats();
}
function historyWeight(count) {
const n = Math.max(1, Number(count) || 1);
const base = Math.min(6, 0.9 + Math.log1p(n) * 1.1);
return clampNumber(base * historyLinkScale, 0.4, 12);
}
function computeHistoryThresholds() {
const counts = [];
historyCache.forEach(edge => {
if (edge && Number.isFinite(edge.count)) {
counts.push(edge.count);
}
});
if (!counts.length) {
return { p70: null, p90: null };
}
counts.sort((a, b) => a - b);
const pick = (pct) => {
const idx = Math.max(0, Math.min(counts.length - 1, Math.floor(pct * (counts.length - 1))));
return counts[idx];
};
const min = counts[0];
const max = counts[counts.length - 1];
let p70 = pick(0.7);
let p90 = pick(0.9);
if (p70 <= min) p70 = min + 1;
if (p90 <= p70) p90 = p70 + 1;
if (p70 > max) {
p70 = max + 1;
p90 = max + 2;
} else if (p90 > max) {
p90 = max + 1;
}
return { p70, p90 };
}
function historyColor(count, thresholds) {
if (!thresholds || thresholds.p90 == null || thresholds.p70 == null) {
return '#7dd3fc';
}
if (count >= thresholds.p90) return '#ef4444';
if (count >= thresholds.p70) return '#f59e0b';
return '#7dd3fc';
}
function historyBand(count, thresholds) {
if (!thresholds || thresholds.p90 == null || thresholds.p70 == null) {
return 'cool';
}
if (count >= thresholds.p90) return 'hot';
if (count >= thresholds.p70) return 'warm';
return 'cool';
}
function historyFilterAllows(count, thresholds) {
if (historyFilterMode === 0) return true;
const band = historyBand(count, thresholds);
if (historyFilterMode === 1) return band === 'cool';
if (historyFilterMode === 2) return band === 'warm';
if (historyFilterMode === 3) return band === 'warm' || band === 'hot';
if (historyFilterMode === 4) return band === 'hot';
return true;
}
function updateHistoryFilterLabel() {
if (!historyFilterLabel) return;
let text = 'All links';
if (historyFilterMode === 1) text = 'Blue links only';
if (historyFilterMode === 2) text = 'Yellow links only';
if (historyFilterMode === 3) text = 'Yellow + Red links';
if (historyFilterMode === 4) text = 'Red links only';
historyFilterLabel.textContent = text;
}
function updateHistoryRendering() {
if (!historyVisible || !nodesVisible) return;
const thresholds = computeHistoryThresholds();
historyLines.forEach(entry => {
if (!entry || !entry.line) return;
const count = Number(entry.count) || 1;
const shouldShow = historyFilterAllows(count, thresholds);
entry.line.setStyle({
color: historyColor(count, thresholds),
weight: shouldShow ? historyWeight(count) : 0.1,
opacity: shouldShow ? 0.6 : 0.0,
lineCap: 'round',
lineJoin: 'round'
});
entry.hidden = !shouldShow;
});
}
function refreshHistoryStyles() {
updateHistoryRendering();
}
function updateHistoryFilter(mode) {
historyFilterMode = Number(mode);
if (![0, 1, 2, 3, 4].includes(historyFilterMode)) {
historyFilterMode = 0;
}
localStorage.setItem('meshmapHistoryFilter', String(historyFilterMode));
updateHistoryFilterLabel();
if (historyVisible && nodesVisible) {
updateHistoryRendering();
}
}
function updateHistoryLinkScale(next) {
const value = Number(next);
if (!Number.isFinite(value)) return;
historyLinkScale = clampNumber(sliderToHistoryScale(value), HISTORY_LINK_MIN, HISTORY_LINK_MAX);
localStorage.setItem('meshmapHistoryLinkScale', String(historyLinkScale));
updateHistoryLinkSizeUI();
if (historyVisible && nodesVisible) {
updateHistoryRendering();
}
}
function historyEdgeId(edge) {
if (!edge) return null;
if (edge.id) return edge.id;
if (Array.isArray(edge.a) && Array.isArray(edge.b)) {
return `${edge.a.join(',')}-${edge.b.join(',')}`;
}
return null;
}
function renderHistoryEdge(edge) {
if (!edge || !Array.isArray(edge.a) || !Array.isArray(edge.b)) return;
const id = historyEdgeId(edge);
if (!id) return;
const points = [
[edge.a[0], edge.a[1]],
[edge.b[0], edge.b[1]]
];
let entry = historyLines.get(id);
if (!entry) {
const line = L.polyline(points, { renderer: vectorRenderer, color: '#7dd3fc', weight: 2, opacity: 0.6 }).addTo(historyLayer);
entry = { line, count: Number(edge.count) || 1, recent: [], lastTs: null };
historyLines.set(id, entry);
line.on('click', (ev) => {
const popup = makeHistoryPopup(entry);
line.bindPopup(popup, {
maxWidth: 300,
autoPan: true,
keepInView: true,
autoPanPadding: [18, 18]
}).openPopup(ev.latlng);
});
} else {
entry.line.setLatLngs(points);
entry.count = Number(edge.count) || entry.count || 1;
}
entry.recent = Array.isArray(edge.recent) ? edge.recent : [];
entry.lastTs = edge.last_ts || entry.lastTs;
refreshHistoryStyles();
}
function renderHistoryFromCache() {
historyCache.forEach(edge => renderHistoryEdge(edge));
updateHistoryRendering();
refreshStats();
}
function upsertHistoryEdge(edge) {
const id = historyEdgeId(edge);
if (!id) return;
const edgeData = { ...edge, id };
historyCache.set(id, edgeData);
if (!historyVisible || !nodesVisible) {
refreshStats();
return;
}
renderHistoryEdge(edgeData);
}
function updateHistoryWindowLabel(seconds) {
const targets = [historyLabel, historyPanelLabel].filter(Boolean);
if (!targets.length) return;
let text = 'History';
if (seconds && seconds > 0) {
const hours = seconds / 3600;
if (hours >= 24) {
text = `History (${Math.round(hours)}h)`;
} else if (hours >= 1) {
text = `History (${Math.round(hours * 10) / 10}h)`;
} else {
text = `History (${Math.max(1, Math.round(seconds / 60))}m)`;
}
}
targets.forEach(el => {
el.textContent = text;
});
}
function refreshHeatLayer() {
if (!heatLayer) return;
const now = Date.now();
const cutoff = now - HEAT_TTL_MS;
const filtered = heatPoints.filter(p => p.ts >= cutoff);
heatPoints.length = 0;
heatPoints.push(...filtered);
if (!heatVisible || !map.hasLayer(heatLayer)) {
return;
}
heatLayer.setLatLngs(heatPoints.map(p => [p.lat, p.lon, p.weight]));
}
function addHeatPoints(points, tsSeconds, payloadType) {
if (!heatLayer) return;
if (!Array.isArray(points) || points.length < 1) return;
const ts = (tsSeconds ? tsSeconds * 1000 : Date.now());
points.forEach(p => {
heatPoints.push({ lat: p[0], lon: p[1], ts, weight: 0.7 });
});
refreshHeatLayer();
}
function seedHeat(items) {
if (!heatLayer) return;
if (!Array.isArray(items)) return;
heatPoints.length = 0;
items.forEach(item => {
if (!Array.isArray(item) || item.length < 3) return;
heatPoints.push({
lat: item[0],
lon: item[1],
ts: item[2] * 1000,
weight: item[3] != null ? item[3] : 0.7
});
});
refreshHeatLayer();
}
function computeRouteDistanceMeters(points) {
if (!Array.isArray(points) || points.length < 2) return 0;
let total = 0;
for (let i = 1; i < points.length; i += 1) {
const prev = points[i - 1];
const next = points[i];
if (!Array.isArray(prev) || !Array.isArray(next)) continue;
const [plat, plon] = prev;
const [nlat, nlon] = next;
if (!Number.isFinite(plat) || !Number.isFinite(plon) || !Number.isFinite(nlat) || !Number.isFinite(nlon)) {
continue;
}
total += haversineMeters(plat, plon, nlat, nlon);
}
return total;
}
function formatSecondsLabel(seconds) {
if (!Number.isFinite(seconds)) return 'unknown';
if (seconds <= 0) return 'expired';
if (seconds < 60) return `${Math.round(seconds)}s`;
const minutes = seconds / 60;
if (minutes < 60) return `${Math.round(minutes)}m`;
const hours = minutes / 60;
if (hours < 24) return `${Math.round(hours)}h`;
const days = hours / 24;
return `${Math.round(days)}d`;
}
function formatTimestampLabel(tsSeconds) {
if (!Number.isFinite(tsSeconds)) return 'unknown';
try {
return new Date(tsSeconds * 1000).toLocaleString();
} catch (err) {
return 'unknown';
}
}
function normalizeHopHashPrefix(hash) {
if (!hash) return null;
const text = String(hash).trim();
if (!text) return null;
const raw = text.toLowerCase().startsWith('0x') ? text.slice(2) : text;
if (!raw) return null;
let normalized = raw.replace(/\s+/g, '');
if (!normalized) return null;
if (!/^[0-9a-fA-F]+$/.test(normalized)) return null;
if (normalized.length % 2 === 1) {
normalized = `0${normalized}`;
}
if (normalized.length !== 2 && normalized.length !== 4 && normalized.length !== 6) {
return null;
}
return normalized.toUpperCase();
}
function hopPrefixIdLabel(hash) {
const normalized = normalizeHopHashPrefix(hash);
if (!normalized) return null;
return normalized;
}
function hopPrefixDetailLabel(hash) {
const normalized = normalizeHopHashPrefix(hash);
if (!normalized) return null;
const displayPrefix = hopPrefixIdLabel(normalized);
const bits = displayPrefix.length * 4;
const decVal = Number.parseInt(displayPrefix, 16);
if (Number.isFinite(decVal)) {
if (normalized.length === 2) {
return `${displayPrefix} (${bits}-bit, ${decVal}, from legacy ${normalized})`;
}
return `${displayPrefix} (${bits}-bit, ${decVal})`;
}
return displayPrefix;
}
function pointIdNodePrefix(pointId, hopHash = null) {
if (!pointId) return null;
const rawPoint = String(pointId).trim();
if (!/^[0-9a-fA-F]+$/.test(rawPoint)) return null;
const normalizedPoint = rawPoint.toUpperCase();
const normalizedHop = normalizeHopHashPrefix(hopHash);
if (normalizedHop && normalizedPoint.startsWith(normalizedHop)) {
return normalizedHop;
}
return normalizedPoint.slice(0, 2) || null;
}
function pointIdNodePrefixDetail(pointId, hopHash = null) {
const prefix = pointIdNodePrefix(pointId, hopHash);
if (!prefix) return null;
const bits = prefix.length * 4;
const decVal = Number.parseInt(prefix, 16);
if (Number.isFinite(decVal)) {
return `${prefix} (${bits}-bit, ${decVal})`;
}
return prefix;
}
function pointIdRoleLabel(pointId) {
if (!pointId) return null;
const device = deviceData.get(pointId);
if (!device) return null;
const role = resolveRole(device);
if (!role || role === 'unknown') return null;
return role.charAt(0).toUpperCase() + role.slice(1);
}
function buildRouteDetailsDisplayPoints(route, pointIds) {
if (!route || !Array.isArray(route.points)) return [];
const rows = route.points.map((pt, idx) => ({
coords: pt,
route_index: idx,
point_id: pointIds ? pointIds[idx] || null : null,
endpoint_only: false,
endpoint_kind: null
}));
const senderName = typeof route.sender_name === 'string' ? route.sender_name.trim() : '';
if (senderName) {
rows.unshift({
coords: null,
route_index: null,
point_id: null,
point_label: senderName,
role_label: 'Companion',
endpoint_only: true,
endpoint_kind: 'sender_name'
});
}
const maybeAddCompanionEndpoint = (deviceId, kind) => {
if (!deviceId) return;
if (pointIds && pointIds.includes(deviceId)) return;
const device = deviceData.get(deviceId);
if (!device || resolveRole(device) !== 'companion') return;
const lat = Number(device.lat);
const lon = Number(device.lon);
const coords = Number.isFinite(lat) && Number.isFinite(lon) ? [lat, lon] : null;
const row = {
coords,
route_index: null,
point_id: deviceId,
endpoint_only: true,
endpoint_kind: kind
};
if (kind === 'origin') {
rows.unshift(row);
} else {
rows.push(row);
}
};
maybeAddCompanionEndpoint(route.origin_id, 'origin');
maybeAddCompanionEndpoint(route.receiver_id, 'receiver');
return rows;
}
function buildRouteLogMeta(route) {
if (!route || !Array.isArray(route.points)) return null;
const pointIds = Array.isArray(route.point_ids) && route.point_ids.length === route.points.length
? route.point_ids
: null;
const displayPoints = buildRouteDetailsDisplayPoints(route, pointIds);
const hopCount = Math.max(0, displayPoints.length - 1);
const distanceMeters = computeRouteDistanceMeters(route.points);
const expiresSeconds = Number(route.expires_at);
const expiresInSeconds = Number.isFinite(expiresSeconds)
? (expiresSeconds - getServerNowMs() / 1000)
: null;
let cumulative = 0;
const hashes = Array.isArray(route.hashes) ? route.hashes : null;
const pointRows = displayPoints.map((entry, idx) => {
const coords = Array.isArray(entry.coords) ? entry.coords : [];
const lat = Number(coords[0]);
const lon = Number(coords[1]);
let hopDistance = null;
if (idx > 0 && Number.isFinite(lat) && Number.isFinite(lon)) {
const prevEntry = displayPoints[idx - 1];
const prevCoords = Array.isArray(prevEntry.coords) ? prevEntry.coords : [];
const prevLat = Number(prevCoords[0]);
const prevLon = Number(prevCoords[1]);
if (Number.isFinite(prevLat) && Number.isFinite(prevLon)) {
hopDistance = haversineMeters(prevLat, prevLon, lat, lon);
if (Number.isFinite(hopDistance)) {
cumulative += hopDistance;
}
}
}
const routeIndex = Number.isInteger(entry.route_index) ? entry.route_index : null;
const hopHash = hashes && routeIndex != null && routeIndex > 0 ? hashes[routeIndex - 1] : null;
const pointId = entry.point_id || null;
return {
index: idx,
lat,
lon,
point_id: pointId || null,
point_label: entry.point_label || (pointId ? deviceLabelFromId(pointId) : null),
role_label: entry.role_label || pointIdRoleLabel(pointId),
endpoint_only: !!entry.endpoint_only,
endpoint_kind: entry.endpoint_kind || null,
node_prefix: pointIdNodePrefix(pointId, hopHash),
node_prefix_detail: pointIdNodePrefixDetail(pointId, hopHash),
hop_distance_m: hopDistance,
hop_distance_label: Number.isFinite(hopDistance) ? formatDistanceUnits(hopDistance) : null,
cumulative_m: cumulative,
cumulative_label: Number.isFinite(cumulative) ? formatDistanceUnits(cumulative) : null,
hop_hash: hopHash || null,
hop_prefix: hopHash ? hopPrefixIdLabel(hopHash) : null,
hop_prefix_detail: hopHash ? hopPrefixDetailLabel(hopHash) : null
};
});
return {
id: route.id,
route_mode: route.route_mode || 'path',
payload_type: route.payload_type,
payload_label: payloadTypeLabel(route.payload_type),
hop_count: hopCount,
point_count: route.points.length,
distance_m: distanceMeters,
distance_label: Number.isFinite(distanceMeters) && distanceMeters > 0 ? formatDistanceUnits(distanceMeters) : 'unknown',
origin_id: route.origin_id,
origin_label: deviceLabelFromId(route.origin_id),
receiver_id: route.receiver_id,
receiver_label: deviceLabelFromId(route.receiver_id),
sender_name: route.sender_name || null,
message_hash: route.message_hash,
topic: route.topic,
snr_values: route.snr_values,
expires_at: expiresSeconds,
expires_label: expiresInSeconds != null ? formatSecondsLabel(expiresInSeconds) : 'unknown',
ts: route.ts,
ts_label: formatTimestampLabel(route.ts),
points: pointRows,
hashes
};
}
function logRouteDetails(meta, clickLatLng) {
if (!meta) {
console.warn('Route details unavailable');
return;
}
const parts = [];
if (meta.payload_label) parts.push(meta.payload_label);
parts.push(meta.route_mode);
parts.push(`${meta.hop_count} hop${meta.hop_count === 1 ? '' : 's'}`);
if (meta.distance_label && meta.distance_label !== 'unknown') parts.push(meta.distance_label);
const title = meta.id ? `Route ${meta.id}` : 'Route';
if (console.groupCollapsed) {
console.groupCollapsed(`${title}${parts.length ? ` (${parts.join(' • ')})` : ''}`);
}
const routeInfo = {
mode: meta.route_mode,
payload: meta.payload_label,
hops: meta.hop_count,
points: meta.point_count,
distance: meta.distance_label,
origin: meta.origin_label,
receiver: meta.receiver_label,
started: meta.ts_label,
expires_in: meta.expires_label,
message_hash: meta.message_hash,
topic: meta.topic,
snr_values: meta.snr_values,
click: clickLatLng ? { lat: clickLatLng.lat, lon: clickLatLng.lng } : undefined
};
console.log('Route details:', routeInfo);
if (Array.isArray(meta.points) && meta.points.length && console.table) {
const rows = meta.points.map((pt) => ({
index: pt.index,
point_id: pt.point_id ? shortHash(pt.point_id) : null,
point_label: pt.point_label || null,
node_prefix: pt.node_prefix || null,
node_prefix_detail: pt.node_prefix_detail || null,
lat: Number.isFinite(pt.lat) ? pt.lat.toFixed(6) : pt.lat,
lon: Number.isFinite(pt.lon) ? pt.lon.toFixed(6) : pt.lon,
hop_distance: pt.hop_distance_label,
cumulative: pt.cumulative_label,
hop_hash: pt.hop_hash ? shortHash(pt.hop_hash) : null,
hop_prefix: pt.hop_prefix || null,
hop_prefix_detail: pt.hop_prefix_detail || null
}));
console.table(rows);
}
if (console.groupCollapsed) {
console.groupEnd();
}
}
function handleRouteClick(routeId, ev) {
if (ev) {
L.DomEvent.stop(ev);
}
const entry = routeLines.get(routeId);
if (!entry) {
console.warn('Route entry missing for click', routeId);
return;
}
if (!entry.meta) {
const latlngs = entry.line.getLatLngs ? entry.line.getLatLngs() : [];
entry.meta = buildRouteLogMeta({ id: routeId, points: latlngs.map(pt => [pt.lat, pt.lng]) });
}
logRouteDetails(entry.meta, ev && ev.latlng);
showRouteDetails(entry.meta);
}
function upsertRoute(r, skipHeat = false) {
if (!r || !Array.isArray(r.points) || r.points.length < 2) return;
const id = r.id || `route-${Date.now()}-${Math.random()}`;
const points = r.points.map(p => [p[0], p[1]]);
const routeMode = r.route_mode || 'path';
const payloadType = Number(r.payload_type);
const style = routeStyleForDisplay(payloadType, routeMode, arcadeModeEnabled);
const routeMeta = buildRouteLogMeta({ ...r, id, points, hashes: r.hashes });
let entry = routeLines.get(id);
if (!entry) {
const line = L.polyline(points, { ...style, renderer: animatedLineRenderer }).addTo(routeLayer);
if (!prodMode) {
line.on('click', (ev) => handleRouteClick(id, ev));
}
entry = { line, timeout: null };
routeLines.set(id, entry);
} else {
entry.line.setLatLngs(points);
entry.line.setStyle(style);
}
entry.meta = routeMeta;
entry.routePoints = points;
entry.payloadType = payloadType;
entry.routeMode = routeMode;
const lineEl = entry.line.getElement();
if (lineEl) {
if (arcadeModeEnabled) {
lineEl.classList.remove('route-animated');
} else {
lineEl.classList.add('route-animated');
}
}
syncArcadeForRoute(id, points);
if (entry.line && entry.line.bringToFront) {
entry.line.bringToFront();
}
if (routeDetailsPanel && !routeDetailsPanel.hidden && activeRouteDetailsId === id) {
showRouteDetails(entry.meta);
}
if (entry.timeout) clearTimeout(entry.timeout);
if (r.expires_at) {
const ms = Math.max(1000, (r.expires_at * 1000) - getServerNowMs());
entry.timeout = setTimeout(() => removeRoutes([id]), ms);
}
if (!skipHeat) {
addHeatPoints(points, r.ts, r.payload_type);
}
if (hopsVisible) {
renderHopMarkers(id, entry.meta);
}
refreshStats();
}
async function initialSnapshot() {
try {
const res = await fetch(withToken('/snapshot'), { headers: tokenHeaders() });
const snap = await res.json();
setServerTimeOffset(snap.server_time);
if (snap.devices) {
for (const [id, d] of Object.entries(snap.devices)) {
const trail = snap.trails ? snap.trails[id] : null;
upsertDevice(d, trail);
}
}
if (Array.isArray(snap.heat)) {
seedHeat(snap.heat);
}
if (Array.isArray(snap.routes)) {
clearRoutes();
snap.routes.forEach(r => upsertRoute(r, true));
}
if (Array.isArray(snap.history_edges)) {
snap.history_edges.forEach(edge => upsertHistoryEdge(edge));
}
applyMqttPresenceSummary(snap.mqtt_presence);
if (snap.history_window_seconds != null) {
historyWindowSeconds = Number(snap.history_window_seconds);
updateHistoryWindowLabel(historyWindowSeconds);
}
if (snap.update) {
setUpdateBanner(snap.update);
}
refreshStats();
} catch (e) {
console.warn("snapshot failed", e);
}
}
const pendingWsMessages = [];
let wsFlushScheduled = false;
function flushQueuedWsMessages() {
wsFlushScheduled = false;
if (!pendingWsMessages.length) return;
const batch = pendingWsMessages.splice(0, pendingWsMessages.length);
deferStats = true;
try {
for (const msg of batch) {
handleRealtimeMessage(msg);
}
} finally {
deferStats = false;
}
scheduleStatsUpdate();
}
function queueRealtimeMessage(msg) {
pendingWsMessages.push(msg);
if (wsFlushScheduled) return;
wsFlushScheduled = true;
window.requestAnimationFrame(flushQueuedWsMessages);
}
function handleRealtimeMessage(msg) {
if (msg.type === "update") {
upsertDevice(msg.device, msg.trail);
return;
}
if (msg.type === "device_seen") {
const id = msg.device_id;
applyMqttPresenceSummary(msg.mqtt_presence);
const d = deviceData.get(id);
if (d) {
if (msg.last_seen_ts) d.last_seen_ts = msg.last_seen_ts;
if (Object.prototype.hasOwnProperty.call(msg, 'mqtt_seen_ts')) d.mqtt_seen_ts = msg.mqtt_seen_ts;
if (Object.prototype.hasOwnProperty.call(msg, 'mqtt_online_source')) d.mqtt_online_source = msg.mqtt_online_source;
if (Object.prototype.hasOwnProperty.call(msg, 'mqtt_status_ts')) d.mqtt_status_ts = msg.mqtt_status_ts;
if (Object.prototype.hasOwnProperty.call(msg, 'mqtt_status_value')) d.mqtt_status_value = msg.mqtt_status_value;
if (Object.prototype.hasOwnProperty.call(msg, 'mqtt_internal_ts')) d.mqtt_internal_ts = msg.mqtt_internal_ts;
if (Object.prototype.hasOwnProperty.call(msg, 'mqtt_packets_ts')) d.mqtt_packets_ts = msg.mqtt_packets_ts;
deviceData.set(id, d);
const m = markers.get(id);
if (m) {
if (m.setStyle) m.setStyle(markerStyleForDevice(d));
m.setPopupContent(makePopup(d));
updateMarkerLabel(m, d);
}
refreshStats();
}
if (!d) refreshStats();
return;
}
if (msg.type === "mqtt_presence") {
applyMqttPresenceSummary(msg.mqtt_presence);
refreshStats();
return;
}
if (msg.type === "route") {
upsertRoute(msg.route);
return;
}
if (msg.type === "route_remove") {
removeRoutes(msg.route_ids || []);
return;
}
if (msg.type === "history_edges") {
const edges = Array.isArray(msg.edges) ? msg.edges : [];
edges.forEach(edge => upsertHistoryEdge(edge));
refreshStats();
return;
}
if (msg.type === "history_edges_remove") {
removeHistoryEdges(msg.edge_ids || []);
return;
}
if (msg.type === "stale") {
removeDevices(msg.device_ids || []);
}
}
function connectWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const wsSuffix = (prodMode && apiToken) ? `?token=${encodeURIComponent(apiToken)}` : '';
const ws = new WebSocket(`${proto}://${location.host}/ws${wsSuffix}`);
ws.onopen = () => console.log("ws connected");
ws.onclose = () => {
console.log("ws disconnected, retrying...");
setTimeout(connectWS, 1500);
};
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === "snapshot") {
// same shape as /snapshot
setServerTimeOffset(msg.server_time);
deferStats = true;
try {
for (const [id, d] of Object.entries(msg.devices || {})) {
const trail = msg.trails ? msg.trails[id] : null;
upsertDevice(d, trail);
}
clearRoutes();
if (Array.isArray(msg.heat)) {
seedHeat(msg.heat);
}
if (Array.isArray(msg.routes)) {
msg.routes.forEach(r => upsertRoute(r, true));
}
if (Array.isArray(msg.history_edges)) {
msg.history_edges.forEach(edge => upsertHistoryEdge(edge));
}
applyMqttPresenceSummary(msg.mqtt_presence);
if (msg.history_window_seconds != null) {
historyWindowSeconds = Number(msg.history_window_seconds);
updateHistoryWindowLabel(historyWindowSeconds);
}
if (msg.update) {
setUpdateBanner(msg.update);
}
} finally {
deferStats = false;
}
setStats();
return;
}
queueRealtimeMessage(msg);
};
}
async function runLosCheck(options = {}) {
const endpoints = getActiveLosSegmentEndpoints();
if (!endpoints) return;
const [a, b] = endpoints;
losComputeLast = Date.now();
const token = ++losComputeToken;
const allowNetwork = options.allowNetwork !== false;
const allowApprox = options.allowApprox === true;
setLosStatus('LOS: calculating...');
try {
const activeLine = getActiveLosLine();
if (activeLine) {
activeLine.setStyle({ color: '#9ca3af', weight: 5, opacity: 0.9, 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;
}
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 (activeLine) {
activeLine.setStyle({ color: '#9ca3af', weight: 5, opacity: 0.9, dashArray: '6 10' });
}
clearLosProfile();
clearLosPeaks();
} catch (err) {
console.warn('los failed', err);
lastLosStatusMeta = null;
setLosStatus('LOS: error');
clearLosProfile();
clearLosPeaks();
}
}
let losPendingOptions = null;
function scheduleLosCheck(force = false, options = {}) {
if (!getActiveLosSegmentEndpoints()) 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();
setInterval(refreshHeatLayer, 15000);
setInterval(refreshOnlineMarkers, 30000);
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => { });
});
}
const hopsToggle = document.getElementById('hops-toggle');
if (hopsToggle) {
hopsToggle.addEventListener('click', () => {
setHopsVisible(!hopsVisible);
});
}
setArcadeModeEnabled(arcadeModeEnabled, false);
const legendToggle = document.getElementById('legend-toggle');
const hud = document.querySelector('.hud');
const hudToggle = document.getElementById('hud-toggle');
if (hud && hudToggle) {
hudToggle.addEventListener('click', (ev) => {
ev.preventDefault();
hud.classList.toggle('panel-hidden');
});
}
if (hud && queryMenuVisible !== null) {
hud.classList.toggle('panel-hidden', !queryMenuVisible);
}
if (legendToggle && hud) {
const storedLegend = localStorage.getItem('meshmapLegendCollapsed');
const overrideLegend = queryLegendVisible === null ? null : !queryLegendVisible;
const initialLegendCollapsed = overrideLegend !== null ? overrideLegend : storedLegend === 'true';
if (initialLegendCollapsed) {
hud.classList.add('legend-collapsed');
legendToggle.textContent = 'Show legend';
}
legendToggle.addEventListener('click', () => {
const collapsed = hud.classList.toggle('legend-collapsed');
legendToggle.textContent = collapsed ? 'Show legend' : 'Hide legend';
localStorage.setItem('meshmapLegendCollapsed', collapsed ? 'true' : 'false');
});
if (overrideLegend !== null) {
localStorage.setItem('meshmapLegendCollapsed', overrideLegend ? 'true' : 'false');
}
}
if (losPanelCollapse) {
losPanelCollapse.addEventListener('click', () => {
setLosPanelCollapsed(!losPanelCollapsed);
});
}
if (propPanelCollapse) {
propPanelCollapse.addEventListener('click', () => {
setPropPanelCollapsed(!propPanelCollapsed);
});
}
if (historyPanelCollapse) {
historyPanelCollapse.addEventListener('click', () => {
setHistoryPanelCollapsed(!historyPanelCollapsed);
});
}
if (peersPanelCollapse) {
peersPanelCollapse.addEventListener('click', () => {
setPeersPanelCollapsed(!peersPanelCollapsed);
});
}
if (routeDetailsCollapse) {
routeDetailsCollapse.addEventListener('click', () => {
setRouteDetailsPanelCollapsed(!routeDetailsPanelCollapsed);
});
}
setLosPanelCollapsed(losPanelCollapsed);
setPropPanelCollapsed(propPanelCollapsed);
setHistoryPanelCollapsed(historyPanelCollapsed);
setPeersPanelCollapsed(peersPanelCollapsed);
setRouteDetailsPanelCollapsed(routeDetailsPanelCollapsed);
const customLink = document.getElementById('custom-link');
const updateBanner = document.getElementById('update-banner');
const updateText = document.getElementById('update-text');
const updateDismiss = document.getElementById('update-dismiss');
let updateDismissKey = null;
const setUpdateBanner = (info) => {
if (!updateBanner) return;
if (!info || !info.available) {
updateBanner.hidden = true;
updateDismissKey = null;
return;
}
const remoteKey = info.remote_short || info.remote || 'update';
updateDismissKey = remoteKey;
const dismissed = localStorage.getItem('meshmapUpdateDismissed');
if (dismissed && dismissed === remoteKey) {
updateBanner.hidden = true;
return;
}
const localLabel = info.local_short || info.local || 'local';
const remoteLabel = info.remote_short || info.remote || 'remote';
if (updateText) {
updateText.textContent = `Update available (${localLabel} \u2192 ${remoteLabel})`;
}
updateBanner.hidden = false;
};
if (customLink) {
if (customLinkUrl) {
customLink.setAttribute('href', customLinkUrl);
customLink.setAttribute('title', customLinkUrl);
} else {
customLink.remove();
}
}
const dismissUpdateBanner = (ev, source = 'click') => {
if (!updateBanner || updateBanner.hidden) return;
if (ev) {
ev.preventDefault();
ev.stopPropagation();
}
const key = updateDismissKey || initialUpdateRemote || 'update';
try {
localStorage.setItem('meshmapUpdateDismissed', key);
} catch (err) {
// ignore storage failures
}
updateBanner.hidden = true;
};
if (updateDismiss) {
updateDismiss.addEventListener('click', (ev) => dismissUpdateBanner(ev, 'click'));
updateDismiss.addEventListener('pointerdown', (ev) => dismissUpdateBanner(ev, 'pointerdown'));
updateDismiss.addEventListener('touchend', (ev) => dismissUpdateBanner(ev, 'touchend'));
}
if (updateBanner) {
updateBanner.addEventListener('click', (ev) => dismissUpdateBanner(ev, 'banner-click'));
updateBanner.addEventListener('pointerdown', (ev) => dismissUpdateBanner(ev, 'banner-pointerdown'));
}
if (initialUpdateAvailable) {
setUpdateBanner({
available: true,
local_short: initialUpdateLocal || null,
remote_short: initialUpdateRemote || null,
local: initialUpdateLocal || null,
remote: initialUpdateRemote || null,
});
}
const shareToggle = document.getElementById('share-toggle');
if (qrModalClose) {
qrModalClose.addEventListener('click', closeQrModal);
}
if (qrModalBackdrop) {
qrModalBackdrop.addEventListener('click', closeQrModal);
}
if (qrModalLabel) {
qrModalLabel.addEventListener('click', copyPopupText);
}
document.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape' && qrModal && !qrModal.hidden) {
closeQrModal();
}
});
if (shareToggle) {
const resetShareButton = () => {
shareToggle.classList.remove('copied');
shareToggle.setAttribute('aria-label', 'Copy share link');
shareToggle.setAttribute('title', 'Copy share link');
};
shareToggle.addEventListener('click', async () => {
const center = map.getCenter();
const url = new URL(window.location.href);
url.searchParams.set('lat', center.lat.toFixed(5));
url.searchParams.set('lon', center.lng.toFixed(5));
url.searchParams.set('zoom', String(map.getZoom()));
url.searchParams.set('layer', baseLayer);
url.searchParams.set('history', historyVisible ? 'on' : 'off');
url.searchParams.set('heat', heatVisible ? 'on' : 'off');
url.searchParams.set('coverage', coverageVisible ? 'on' : 'off');
url.searchParams.set('weather', radarVisible ? 'on' : 'off');
url.searchParams.set('weather_radar', weatherRadarLayerEnabled ? 'on' : 'off');
url.searchParams.set('weather_wind', weatherWindLayerEnabled ? 'on' : 'off');
url.searchParams.set('labels', showLabels ? 'on' : 'off');
url.searchParams.set('nodes', nodesVisible ? 'on' : 'off');
url.searchParams.set('legend', hud && hud.classList.contains('legend-collapsed') ? 'off' : 'on');
url.searchParams.set('menu', hud && hud.classList.contains('panel-hidden') ? 'off' : 'on');
url.searchParams.set('units', distanceUnits);
url.searchParams.set('history_filter', String(historyFilterMode));
const shareUrl = url.toString();
let copied = false;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(shareUrl);
copied = true;
}
} catch (err) {
copied = false;
}
if (!copied) {
window.prompt('Copy share link:', shareUrl);
}
shareToggle.classList.add('copied');
shareToggle.setAttribute('aria-label', 'Share link copied');
shareToggle.setAttribute('title', 'Share link copied');
window.setTimeout(resetShareButton, 1600);
});
}
const mapToggle = document.getElementById('map-toggle');
const topoToggle = document.getElementById('topo-toggle');
function setBaseLayer(name) {
if (map.hasLayer(lightTiles)) map.removeLayer(lightTiles);
if (map.hasLayer(darkTiles)) map.removeLayer(darkTiles);
if (map.hasLayer(topoTiles)) map.removeLayer(topoTiles);
if (name === 'dark') {
map.addLayer(darkTiles);
} else if (name === 'topo') {
map.addLayer(topoTiles);
} else {
map.addLayer(lightTiles);
}
document.body.classList.toggle('dark-map', name === 'dark');
baseLayer = name;
localStorage.setItem('meshmapBaseLayer', baseLayer);
if (mapToggle) {
mapToggle.textContent = baseLayer === 'dark' ? 'Light map' : 'Dark map';
}
if (topoToggle) {
topoToggle.textContent = baseLayer === 'topo' ? 'Standard map' : 'Topo map';
}
}
if (mapToggle) {
mapToggle.addEventListener('click', () => {
setBaseLayer(baseLayer === 'dark' ? 'light' : 'dark');
});
}
if (topoToggle) {
topoToggle.addEventListener('click', () => {
setBaseLayer(baseLayer === 'topo' ? 'light' : 'topo');
});
}
setBaseLayer(baseLayer);
const unitsToggle = document.getElementById('units-toggle');
function setUnitsLabel() {
if (!unitsToggle) return;
unitsToggle.textContent = distanceUnits === 'mi' ? 'Units: mi' : 'Units: km';
}
function setDistanceUnits(units, persist = true) {
if (!validUnits.has(units)) return;
distanceUnits = units;
if (persist) {
localStorage.setItem('meshmapDistanceUnits', units);
}
setUnitsLabel();
if (lastLosDistance != null && losProfileData.length) {
updateLosProfileAtDistance(lastLosDistance);
}
if (lastLosStatusMeta) {
setLosStatus(buildLosStatus(lastLosStatusMeta));
}
updatePropagationSummary();
if (propagationRasterMeta && propagationOrigins.length) {
updatePropagationStatusFromRaster();
}
if (activeRouteDetailsMeta && routeDetailsPanel && !routeDetailsPanel.hidden) {
showRouteDetails(activeRouteDetailsMeta);
}
if (radarVisible && weatherWindEnabled && weatherWindLayerEnabled) {
refreshWeatherWindLayer({ silent: true, background: true });
}
}
setUnitsLabel();
if (unitsToggle) {
unitsToggle.addEventListener('click', () => {
const next = distanceUnits === 'mi' ? 'km' : 'mi';
setDistanceUnits(next);
});
}
const labelsToggle = document.getElementById('labels-toggle');
if (labelsToggle) {
labelsToggle.addEventListener('click', () => {
setLabelsActive(!showLabels);
});
}
if (queryLabelsVisible !== null) {
showLabels = queryLabelsVisible;
localStorage.setItem('meshmapShowLabels', showLabels ? 'true' : 'false');
}
setLabelsActive(showLabels);
if (searchInput) {
searchInput.addEventListener('input', (ev) => {
renderSearchResults(ev.target.value);
});
searchInput.addEventListener('keydown', (ev) => {
const secret = String(searchInput.value || '').trim().toLowerCase();
if (ev.key === 'Enter' && (secret === '/waka' || secret === '/arcade')) {
ev.preventDefault();
searchInput.value = '';
renderSearchResults('');
toggleArcadeMode();
return;
}
if (ev.key === 'Enter' && searchMatches.length > 0) {
ev.preventDefault();
focusDevice(searchMatches[0].id);
}
});
}
document.addEventListener('click', (ev) => {
if (!searchResults || !searchInput) return;
if (searchResults.contains(ev.target) || searchInput.contains(ev.target)) return;
searchResults.hidden = true;
searchResults.innerHTML = '';
});
window.addEventListener('resize', () => {
layoutSidePanels();
});
if (losProfileSvg) {
losProfileSvg.addEventListener('mousemove', updateLosProfileHover);
losProfileSvg.addEventListener('mouseleave', clearLosProfileHover);
losProfileSvg.addEventListener('click', (ev) => {
const distance = losProfileDistanceFromEvent(ev);
if (distance == null) return;
updateLosProfileAtDistance(distance);
copyLosCoords(distance);
});
}
const losToggle = document.getElementById('los-toggle');
if (losToggle) {
losToggle.addEventListener('click', () => {
setLosActive(!losActive);
});
}
const parseLosHeightValue = (input) => {
if (!input) return 0;
const value = Number(input.value);
return Number.isFinite(value) ? value : 0;
};
const handleLosHeightChange = () => {
if (!Number.isInteger(losActiveSegmentIndex) || losActiveSegmentIndex < 0) return;
setLosPointHeight(losActiveSegmentIndex, parseLosHeightValue(losHeightAInput));
setLosPointHeight(losActiveSegmentIndex + 1, parseLosHeightValue(losHeightBInput));
if (losPoints.length >= 2) {
if (losActiveSegmentIndex >= 0 && losActiveSegmentIndex < losSegmentMeta.length) {
losSegmentMeta[losActiveSegmentIndex] = null;
}
scheduleLosCheck(true, { allowNetwork: true, forceNetwork: true });
}
};
if (losHeightAInput) {
losHeightAInput.addEventListener('input', handleLosHeightChange);
losHeightAInput.addEventListener('change', handleLosHeightChange);
}
if (losHeightBInput) {
losHeightBInput.addEventListener('input', handleLosHeightChange);
losHeightBInput.addEventListener('change', handleLosHeightChange);
}
function updateLosPointPosition(index, latlng) {
if (index == null || !losPoints[index]) return;
losPoints[index] = latlng;
ensureLosSegments();
const affected = [];
if (index > 0) affected.push(index - 1);
if (index < losLines.length) affected.push(index);
affected.forEach((segIdx) => {
if (segIdx >= 0 && segIdx < losSegmentMeta.length) {
losSegmentMeta[segIdx] = null;
}
});
if (affected.length) {
losActiveSegmentIndex = affected[affected.length - 1];
}
lastLosStatusMeta = null;
updateLosSegmentStyles();
if (losPoints.length >= 2) {
setLosStatus('LOS: calculating...');
}
return affected;
}
async function recomputeLosSegments(indices, options = {}) {
const valid = Array.from(new Set((indices || []).filter((index) => (
Number.isInteger(index) && index >= 0 && index < losLines.length
))));
if (!valid.length) return;
const finalIndex = Number.isInteger(options.finalIndex) && options.finalIndex >= 0 && options.finalIndex < losLines.length
? options.finalIndex
: valid[valid.length - 1];
for (const index of valid) {
selectLosSegment(index, { silent: true });
await runLosCheck({
allowNetwork: options.allowNetwork !== false,
allowApprox: options.allowApprox === true,
forceNetwork: options.forceNetwork === true
});
}
if (Number.isInteger(finalIndex) && finalIndex >= 0 && finalIndex < losLines.length) {
selectLosSegment(finalIndex, { silent: true });
}
}
function removeLastLosPoint() {
if (!losPoints.length) return;
const lastMarker = losPointMarkers.pop();
if (lastMarker) {
losLayer.removeLayer(lastMarker);
}
losPoints.pop();
losPointHeights.pop();
persistLosPointHeights();
if (losSelectedPointIndex != null && losSelectedPointIndex >= losPoints.length) {
setLosSelectedPoint(null);
}
ensureLosSegments();
if (losPoints.length >= 2) {
const nextIndex = Math.max(0, losPoints.length - 2);
selectLosSegment(nextIndex, { silent: true });
const combined = buildCombinedLosProfile();
if (combined.profile.length >= 2) {
renderLosProfile(combined.profile, combined.blocked, combined);
} else {
clearLosProfile();
}
setLosStatus(`LOS: removed pin ${losPoints.length + 1}`);
return;
}
clearLosProfile();
clearLosPeaks();
lastLosStatusMeta = null;
if (losPoints.length === 1) {
setLosStatus('LOS: select next point');
} else {
setLosStatus('LOS: select first point (Shift+click or long-press nodes)');
}
}
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);
if (losLines.length > 0) {
const nextSegment = index > 0 ? index - 1 : 0;
selectLosSegment(nextSegment, { silent: true });
}
setLosStatus(`LOS: selected point ${index + 1} (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', async () => {
const el = marker.getElement();
if (el) el.classList.remove('dragging');
const next = marker.getLatLng();
const affected = updateLosPointPosition(index, next);
losDragging = false;
await recomputeLosSegments(affected, {
allowNetwork: true,
forceNetwork: true,
finalIndex: affected && affected.length ? affected[affected.length - 1] : losActiveSegmentIndex
});
});
marker.on('add', () => setLosSelectedPoint(losSelectedPointIndex));
return marker;
}
function handleLosPoint(latlng) {
losPoints.push(latlng);
if (losPointHeights.length < losPoints.length) {
losPointHeights.push(0);
persistLosPointHeights();
}
const marker = createLosPointMarker(latlng, losPoints.length - 1);
losPointMarkers.push(marker);
setLosSelectedPoint(null);
if (losPoints.length === 1) {
setLosStatus('LOS: select next point');
return;
}
losLocked = true;
ensureLosSegments();
selectLosSegment(losPoints.length - 2, { silent: true });
scheduleLosCheck(true, { allowNetwork: true, forceNetwork: true });
}
if (losClearButton) {
losClearButton.addEventListener('click', () => {
clearLos();
if (losActive) {
setLosStatus('LOS: select first point (Shift+click or long-press nodes)');
}
});
}
if (losRemoveLastButton) {
losRemoveLastButton.addEventListener('click', () => {
removeLastLosPoint();
});
}
const nodesToggle = document.getElementById('nodes-toggle');
if (nodesToggle) {
const storedNodes = localStorage.getItem('meshmapNodesVisible');
let initialNodes = storedNodes !== null ? storedNodes === 'true' : true;
if (queryNodesVisible !== null) {
initialNodes = queryNodesVisible;
localStorage.setItem('meshmapNodesVisible', initialNodes ? 'true' : 'false');
}
setNodesVisible(initialNodes);
nodesToggle.addEventListener('click', () => {
setNodesVisible(!nodesVisible);
localStorage.setItem('meshmapNodesVisible', nodesVisible ? 'true' : 'false');
});
}
updateNodeSizeUi();
if (nodeSizeInput) {
nodeSizeInput.addEventListener('input', (ev) => {
setNodeMarkerRadius(ev.target.value);
});
}
const historyToggle = document.getElementById('history-toggle');
if (historyToggle) {
let initialHistory = false;
if (queryHistoryVisible !== null) {
initialHistory = queryHistoryVisible;
}
setHistoryVisible(initialHistory);
historyToggle.addEventListener('click', () => {
if (historyVisible && historyPanelHidden) {
setHistoryPanelHidden(false);
return;
}
setHistoryVisible(!historyVisible);
});
}
updateHistoryFilterLabel();
if (historyFilter) {
historyFilter.addEventListener('input', (ev) => {
updateHistoryFilter(ev.target.value);
});
}
if (historyLinkSizeInput) {
historyLinkSizeInput.addEventListener('input', (ev) => {
updateHistoryLinkScale(ev.target.value);
});
}
if (peersToggle) {
setPeersActive(false);
peersToggle.addEventListener('click', () => {
setPeersActive(!peersActive);
});
}
if (peersClear) {
peersClear.addEventListener('click', () => {
clearPeers();
});
}
const heatToggle = document.getElementById('heat-toggle');
if (heatToggle) {
const storedHeatVisible = localStorage.getItem('meshmapShowHeat');
let initialHeat = storedHeatVisible !== null ? storedHeatVisible === 'true' : true;
if (queryHeatVisible !== null) {
initialHeat = queryHeatVisible;
localStorage.setItem('meshmapShowHeat', initialHeat ? 'true' : 'false');
}
setHeatVisible(initialHeat);
heatToggle.addEventListener('click', () => {
try {
setHeatVisible(!heatVisible);
localStorage.setItem('meshmapShowHeat', heatVisible ? 'true' : 'false');
} catch (err) {
reportError(`Heat toggle failed: ${err && err.message ? err.message : err}`);
console.error(err);
}
});
}
const coverageToggle = document.getElementById('coverage-toggle');
if (coverageToggle && !coverageEnabled) {
coverageToggle.setAttribute('hidden', 'hidden');
}
if (coverageToggle && coverageEnabled) {
const storedCoverageVisible = localStorage.getItem('meshmapShowCoverage');
let initialCoverage = storedCoverageVisible !== null ? storedCoverageVisible === 'true' : false;
if (queryCoverageVisible !== null) {
initialCoverage = queryCoverageVisible;
localStorage.setItem('meshmapShowCoverage', initialCoverage ? 'true' : 'false');
}
setCoverageVisible(initialCoverage);
coverageToggle.addEventListener('click', () => {
try {
setCoverageVisible(!coverageVisible);
localStorage.setItem('meshmapShowCoverage', coverageVisible ? 'true' : 'false');
} catch (err) {
reportError(`Coverage toggle failed: ${err && err.message ? err.message : err}`);
}
});
}
const weatherToggle = document.getElementById('weather-toggle');
if (weatherToggle) {
localStorage.removeItem('meshmapShowRadar');
if (!weatherAvailable) {
weatherToggle.setAttribute('hidden', 'hidden');
if (weatherPanel) {
weatherPanel.setAttribute('hidden', 'hidden');
weatherPanel.classList.remove('active');
weatherPanel.style.display = 'none';
}
}
if (weatherRadarEnabled && weatherRadarCountryBoundsEnabled) {
loadStoredRadarCountryBounds();
ensureRadarCountryBounds({ silent: true });
}
const cachedRadarHost = (localStorage.getItem('meshmapRadarHost') || '').trim();
const cachedRadarPath = (localStorage.getItem('meshmapRadarPath') || '').trim();
if (weatherRadarEnabled && cachedRadarPath) {
updateRadarLayer(cachedRadarHost || radarFrameHost, cachedRadarPath);
}
if (weatherRadarEnabled) {
refreshRadarLayer({ silent: true, background: true });
}
const storedWeatherRadarLayer = parseBoolParam(
localStorage.getItem(WEATHER_RADAR_LAYER_STORAGE_KEY)
);
const storedWeatherWindLayer = parseBoolParam(
localStorage.getItem(WEATHER_WIND_LAYER_STORAGE_KEY)
);
weatherRadarLayerEnabled = weatherRadarEnabled && (
queryWeatherRadarVisible !== null
? queryWeatherRadarVisible
: (storedWeatherRadarLayer !== null ? storedWeatherRadarLayer : true)
);
if (weatherWindEnabled) {
weatherWindLayerEnabled = queryWeatherWindVisible !== null
? queryWeatherWindVisible
: (storedWeatherWindLayer !== null ? storedWeatherWindLayer : true);
} else {
weatherWindLayerEnabled = false;
}
try {
localStorage.setItem(
WEATHER_RADAR_LAYER_STORAGE_KEY,
weatherRadarLayerEnabled ? 'true' : 'false'
);
localStorage.setItem(
WEATHER_WIND_LAYER_STORAGE_KEY,
weatherWindLayerEnabled ? 'true' : 'false'
);
} catch (_err) {}
updateWeatherLayerButtons();
let initialRadar = false;
if (queryWeatherVisible !== null && weatherAvailable) {
initialRadar = queryWeatherVisible;
}
setRadarVisible(initialRadar);
weatherToggle.addEventListener('click', () => {
if (!weatherAvailable) return;
try {
if (radarVisible && weatherPanelHidden) {
setWeatherPanelHidden(false);
return;
}
setRadarVisible(!radarVisible);
} catch (err) {
reportError(`Weather toggle failed: ${err && err.message ? err.message : err}`);
}
});
}
if (weatherRadarLayerToggle) {
weatherRadarLayerToggle.addEventListener('click', () => {
if (!weatherRadarEnabled) return;
setWeatherRadarLayerEnabled(!weatherRadarLayerEnabled);
});
}
if (weatherWindLayerToggle) {
weatherWindLayerToggle.addEventListener('click', () => {
if (!weatherWindEnabled) return;
setWeatherWindLayerEnabled(!weatherWindLayerEnabled);
});
}
if (weatherHideButton) {
const hideWeatherPanel = (ev) => {
if (ev) {
if (ev.preventDefault) ev.preventDefault();
if (ev.stopPropagation) ev.stopPropagation();
if (typeof L !== 'undefined' && L.DomEvent) L.DomEvent.stop(ev);
}
setWeatherPanelHidden(true);
};
weatherHideButton.addEventListener('click', hideWeatherPanel);
weatherHideButton.addEventListener('pointerdown', hideWeatherPanel);
}
const propToggle = document.getElementById('prop-toggle');
const propTxInput = document.getElementById('prop-txpower');
const propTxGainInput = document.getElementById('prop-tx-gain');
const propOpacityInput = document.getElementById('prop-opacity');
const propModelSelect = document.getElementById('prop-model');
const propTerrainInput = document.getElementById('prop-terrain');
const propTxAglInput = document.getElementById('prop-tx-agl');
const propRxAglInput = document.getElementById('prop-rx-agl');
const propTxMslInput = document.getElementById('prop-tx-msl');
const propRxMslInput = document.getElementById('prop-rx-msl');
const propMinRxInput = document.getElementById('prop-min-rx');
const propAutoRangeInput = document.getElementById('prop-auto-range');
const propMultiOriginInput = document.getElementById('prop-multi-origin');
const propFadeMarginInput = document.getElementById('prop-fade-margin');
const propWebGpuInput = document.getElementById('prop-webgpu');
const propClearOriginsButton = document.getElementById('prop-clear-origins');
const propAutoResInput = document.getElementById('prop-auto-res');
const propMaxCellsInput = document.getElementById('prop-max-cells');
const propGridInput = document.getElementById('prop-grid');
const propSampleInput = document.getElementById('prop-sample');
const propRangeFactorInput = document.getElementById('prop-range-factor');
const propRenderButton = document.getElementById('prop-render');
if (propTxInput) {
const storedTx = localStorage.getItem('meshmapPropTxPower');
if (storedTx !== null) propTxInput.value = storedTx;
propTxInput.addEventListener('input', () => {
localStorage.setItem('meshmapPropTxPower', propTxInput.value);
updatePropagationSummary();
markPropagationDirty();
});
}
if (propTxGainInput) {
const storedTxGain = localStorage.getItem('meshmapPropTxGain');
if (storedTxGain !== null) propTxGainInput.value = storedTxGain;
propTxGainInput.addEventListener('input', () => {
localStorage.setItem('meshmapPropTxGain', propTxGainInput.value);
updatePropagationSummary();
markPropagationDirty();
});
}
if (propOpacityInput) {
const storedOpacity = localStorage.getItem('meshmapPropOpacity');
if (storedOpacity !== null) propOpacityInput.value = storedOpacity;
propOpacityInput.addEventListener('input', () => {
localStorage.setItem('meshmapPropOpacity', propOpacityInput.value);
if (propagationRaster) {
propagationRaster.setOpacity(Number(propOpacityInput.value));
}
if (propagationLastConfig) {
propagationLastConfig.opacity = Number(propOpacityInput.value);
}
});
}
if (propModelSelect) {
const storedModel = localStorage.getItem('meshmapPropModel');
if (storedModel && PROP_MODELS[storedModel]) {
propModelSelect.value = storedModel;
}
propModelSelect.addEventListener('change', () => {
localStorage.setItem('meshmapPropModel', propModelSelect.value);
updatePropagationSummary();
markPropagationDirty();
});
}
if (propTerrainInput) {
const storedTerrain = localStorage.getItem('meshmapPropTerrain');
if (storedTerrain !== null) propTerrainInput.checked = storedTerrain === 'true';
propTerrainInput.addEventListener('change', () => {
localStorage.setItem('meshmapPropTerrain', propTerrainInput.checked ? 'true' : 'false');
updatePropagationSummary();
markPropagationDirty();
});
}
if (propTxAglInput) {
const storedTxAgl = localStorage.getItem('meshmapPropTxAgl');
if (storedTxAgl !== null) propTxAglInput.value = storedTxAgl;
propTxAglInput.addEventListener('input', () => {
localStorage.setItem('meshmapPropTxAgl', propTxAglInput.value);
updatePropagationSummary();
markPropagationDirty();
});
}
if (propRxAglInput) {
const storedRxAgl = localStorage.getItem('meshmapPropRxAgl');
if (storedRxAgl !== null) propRxAglInput.value = storedRxAgl;
propRxAglInput.addEventListener('input', () => {
localStorage.setItem('meshmapPropRxAgl', propRxAglInput.value);
updatePropagationSummary();
markPropagationDirty();
});
}
if (propTxMslInput) {
const storedTxMsl = localStorage.getItem('meshmapPropTxMsl');
if (storedTxMsl !== null) propTxMslInput.value = storedTxMsl;
propTxMslInput.addEventListener('input', () => {
localStorage.setItem('meshmapPropTxMsl', propTxMslInput.value);
updatePropagationSummary();
markPropagationDirty();
});
}
if (propRxMslInput) {
const storedRxMsl = localStorage.getItem('meshmapPropRxMsl');
if (storedRxMsl !== null) propRxMslInput.value = storedRxMsl;
propRxMslInput.addEventListener('input', () => {
localStorage.setItem('meshmapPropRxMsl', propRxMslInput.value);
updatePropagationSummary();
markPropagationDirty();
});
}
if (propMinRxInput) {
const storedMinRx = localStorage.getItem('meshmapPropMinRx');
if (storedMinRx !== null) propMinRxInput.value = storedMinRx;
propMinRxInput.addEventListener('input', () => {
localStorage.setItem('meshmapPropMinRx', propMinRxInput.value);
updatePropagationSummary();
markPropagationDirty();
});
}
if (propAutoRangeInput) {
const storedAutoRange = localStorage.getItem('meshmapPropAutoRange');
if (storedAutoRange !== null) propAutoRangeInput.checked = storedAutoRange === 'true';
propAutoRangeInput.addEventListener('change', () => {
localStorage.setItem('meshmapPropAutoRange', propAutoRangeInput.checked ? 'true' : 'false');
if (propRangeFactorInput) {
propRangeFactorInput.disabled = propAutoRangeInput.checked;
}
updatePropagationSummary();
markPropagationDirty();
});
if (propRangeFactorInput) {
propRangeFactorInput.disabled = propAutoRangeInput.checked;
}
}
if (propMultiOriginInput) {
const storedMulti = localStorage.getItem('meshmapPropMultiOrigin');
if (storedMulti !== null) propMultiOriginInput.checked = storedMulti === 'true';
propMultiOriginInput.addEventListener('change', () => {
localStorage.setItem('meshmapPropMultiOrigin', propMultiOriginInput.checked ? 'true' : 'false');
if (!propMultiOriginInput.checked && propagationOrigins.length > 1) {
const first = propagationOrigins[0];
clearPropagationOrigins();
propagationOrigins = [first];
upsertPropagationOriginMarker(first);
updatePropagationSummary();
markPropagationDirty('Multi-origin disabled. Keeping first origin only.');
} else {
markPropagationDirty();
if (propagationActive && propMultiOriginInput.checked && !propagationOrigins.length) {
setPropStatus('Multi-origin enabled. Click nodes or the map to add transmitters.');
}
}
});
}
if (propFadeMarginInput) {
const storedFade = localStorage.getItem('meshmapPropFadeMargin');
if (storedFade !== null) propFadeMarginInput.checked = storedFade === 'true';
propFadeMarginInput.addEventListener('change', () => {
localStorage.setItem('meshmapPropFadeMargin', propFadeMarginInput.checked ? 'true' : 'false');
markPropagationDirty();
});
}
if (propWebGpuInput) {
const supported = !!navigator.gpu;
propWebGpuInput.disabled = !supported;
const storedWebGpu = localStorage.getItem('meshmapPropWebGpu');
if (storedWebGpu !== null) propWebGpuInput.checked = storedWebGpu === 'true';
if (!supported) propWebGpuInput.checked = false;
propWebGpuInput.addEventListener('change', () => {
localStorage.setItem('meshmapPropWebGpu', propWebGpuInput.checked ? 'true' : 'false');
markPropagationDirty();
if (propagationActive && propWebGpuInput.checked && !supported) {
setPropStatus('WebGPU not supported in this browser.');
}
});
}
if (propClearOriginsButton) {
propClearOriginsButton.addEventListener('click', () => {
clearPropagationOrigins();
resetPropagationRaster();
setPropStatus('Select a node or click the map to set a transmitter.');
updatePropagationSummary();
});
}
if (propAutoResInput) {
const storedAutoRes = localStorage.getItem('meshmapPropAutoRes');
if (storedAutoRes !== null) propAutoResInput.checked = storedAutoRes === 'true';
propAutoResInput.addEventListener('change', () => {
localStorage.setItem('meshmapPropAutoRes', propAutoResInput.checked ? 'true' : 'false');
if (propMaxCellsInput) {
propMaxCellsInput.disabled = !propAutoResInput.checked;
}
updatePropagationSummary();
markPropagationDirty();
});
if (propMaxCellsInput) {
propMaxCellsInput.disabled = !propAutoResInput.checked;
}
}
if (propMaxCellsInput) {
const storedMaxCells = localStorage.getItem('meshmapPropMaxCells');
if (storedMaxCells !== null) propMaxCellsInput.value = storedMaxCells;
propMaxCellsInput.addEventListener('input', () => {
localStorage.setItem('meshmapPropMaxCells', propMaxCellsInput.value);
updatePropagationSummary();
markPropagationDirty();
});
}
if (propGridInput) {
const storedGrid = localStorage.getItem('meshmapPropGrid');
if (storedGrid !== null) propGridInput.value = storedGrid;
propGridInput.addEventListener('input', () => {
localStorage.setItem('meshmapPropGrid', propGridInput.value);
updatePropagationSummary();
markPropagationDirty();
});
}
if (propSampleInput) {
const storedSample = localStorage.getItem('meshmapPropSample');
if (storedSample !== null) propSampleInput.value = storedSample;
propSampleInput.addEventListener('input', () => {
localStorage.setItem('meshmapPropSample', propSampleInput.value);
updatePropagationSummary();
markPropagationDirty();
});
}
if (propRangeFactorInput) {
const storedRange = localStorage.getItem('meshmapPropRangeFactor');
if (storedRange !== null) propRangeFactorInput.value = storedRange;
propRangeFactorInput.addEventListener('input', () => {
localStorage.setItem('meshmapPropRangeFactor', propRangeFactorInput.value);
updatePropagationSummary();
markPropagationDirty();
});
}
if (propRenderButton) {
propRenderButton.addEventListener('click', () => {
renderPropagationRaster();
});
}
if (propToggle) {
propToggle.addEventListener('click', () => {
setPropActive(!propagationActive);
if (propagationActive) {
updatePropagationSummary();
if (propagationOrigins.length) {
const label = propagationOrigins.length === 1 ? 'Origin set.' : `${propagationOrigins.length} origins set.`;
setPropStatus(`${label} Click "Render prop" to calculate coverage.`);
}
}
});
}
map.on('moveend', () => {
refreshViewportLayers();
scheduleWeatherWindRefresh();
});
map.on('zoomend', () => {
refreshViewportLayers();
scheduleWeatherWindRefresh();
});
map.on('click', (ev) => {
const target = ev && ev.originalEvent ? ev.originalEvent.target : null;
if (target && target.closest && target.closest('.leaflet-popup')) {
return;
}
if (losActive) {
if (losLocked && losSelectedPointIndex != null) {
const idx = losSelectedPointIndex;
if (losPointMarkers[idx]) {
losPointMarkers[idx].setLatLng(ev.latlng);
}
const affected = updateLosPointPosition(idx, ev.latlng);
setLosSelectedPoint(null);
recomputeLosSegments(affected, {
allowNetwork: true,
forceNetwork: true,
finalIndex: affected && affected.length ? affected[affected.length - 1] : losActiveSegmentIndex
});
return;
}
handleLosPoint(ev.latlng);
return;
}
if (propagationActive) {
setPropagationOrigin(ev.latlng);
}
});