mirror of
https://github.com/yellowcooln/meshcore-mqtt-live-map.git
synced 2026-04-20 23:23:36 +00:00
5234 lines
176 KiB
JavaScript
5234 lines
176 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 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 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 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';
|
|
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 }).setView([mapStartLat, mapStartLon], mapStartZoom);
|
|
L.control.zoom({ position: 'bottomright' }).addTo(map);
|
|
const lightTiles = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
maxZoom: 19,
|
|
attribution: '© OpenStreetMap contributors'
|
|
}).addTo(map);
|
|
const darkTiles = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
|
maxZoom: 19,
|
|
attribution: '© OpenStreetMap contributors © CARTO'
|
|
});
|
|
const topoTiles = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
|
|
maxZoom: 17,
|
|
attribution: '© OpenStreetMap contributors © OpenTopoMap'
|
|
});
|
|
let mapRadiusCircle = null;
|
|
if (mapRadiusShow && 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);
|
|
}
|
|
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 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
|
|
const historyLayer = L.layerGroup();
|
|
const peerLayer = L.layerGroup();
|
|
const peerLines = new Map(); // peer_id -> line
|
|
const routeLayer = L.layerGroup().addTo(map);
|
|
const hopLayer = L.layerGroup();
|
|
const hopMarkers = new Map(); // route_id -> [markers]
|
|
let hopsVisible = false;
|
|
const losElevationUrl = config.losElevationUrl || 'https://api.opentopodata.org/v1/srtm90m';
|
|
const losElevationProxyUrl = (config.losElevationProxyUrl || '/los/elevations').trim();
|
|
const losElevationFetchUrl = (() => {
|
|
if (!losElevationUrl) return losElevationProxyUrl;
|
|
try {
|
|
const parsed = new URL(losElevationUrl, window.location.origin);
|
|
if (parsed.origin === window.location.origin) {
|
|
return parsed.toString();
|
|
}
|
|
} catch (err) {
|
|
// ignore malformed URL and fall back to proxy
|
|
}
|
|
return losElevationProxyUrl || losElevationUrl;
|
|
})();
|
|
const losSampleMin = Number(config.losSampleMin) || 10;
|
|
const losSampleMax = Number(config.losSampleMax) || 80;
|
|
const losSampleStepMeters = Number(config.losSampleStepMeters) || 250;
|
|
const losPeaksMax = Number(config.losPeaksMax) || 4;
|
|
const mqttOnlineSeconds = Number(config.mqttOnlineSeconds) || 300;
|
|
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 coverageEnabled = Boolean(coverageApiUrl);
|
|
const coverageLayer = L.layerGroup();
|
|
let coverageVisible = false;
|
|
let coverageData = null;
|
|
let losActive = false;
|
|
let losPoints = [];
|
|
let losLine = 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 losPanel = document.getElementById('los-panel');
|
|
const losHeightAInput = document.getElementById('los-height-a');
|
|
const losKeepAInput = document.getElementById('los-keep-a');
|
|
const losHeightBInput = document.getElementById('los-height-b');
|
|
const propPanel = document.getElementById('prop-panel');
|
|
const historyPanel = document.getElementById('history-panel');
|
|
const historyLegendGroup = document.getElementById('legend-history-group');
|
|
const historyPanelLabel = document.getElementById('history-panel-label');
|
|
const historyHideButton = document.getElementById('history-hide');
|
|
const peersPanel = document.getElementById('peers-panel');
|
|
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 routeDetailsHide = document.getElementById('route-details-hide');
|
|
let activeRouteDetailsMeta = null;
|
|
let activeRouteDetailsId = null;
|
|
let losProfileData = [];
|
|
let losProfileMeta = null;
|
|
let losPointMarkers = [];
|
|
let losSelectedPointIndex = null;
|
|
const storedLosHeightA = parseNumberParam(localStorage.getItem('meshmapLosHeightA'));
|
|
const storedLosHeightB = parseNumberParam(localStorage.getItem('meshmapLosHeightB'));
|
|
let losHeightA = Number.isFinite(storedLosHeightA) ? storedLosHeightA : 0;
|
|
let losHeightB = Number.isFinite(storedLosHeightB) ? storedLosHeightB : 0;
|
|
const syncLosHeightInputs = () => {
|
|
if (losHeightAInput) losHeightAInput.value = String(losHeightA);
|
|
if (losHeightBInput) losHeightBInput.value = String(losHeightB);
|
|
};
|
|
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 peersActive = false;
|
|
let peersSelectedId = null;
|
|
let peersData = null;
|
|
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) {
|
|
mqttWindowLabel.textContent = `MQTT online (last ${formatOnlineWindow(mqttOnlineSeconds)})`;
|
|
}
|
|
|
|
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 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 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 setStats() {
|
|
const onlineCount = Array.from(deviceData.values()).filter(isMqttOnline).length;
|
|
document.getElementById('stats').textContent = `${markers.size} active devices • ${onlineCount} MQTT online • ${routeLines.size} routes • ${historyLines.size} history`;
|
|
}
|
|
|
|
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;
|
|
} catch (err) {
|
|
const errorMsg = err && err.message ? err.message : String(err);
|
|
reportError(`Failed to fetch coverage data: ${errorMsg}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function renderCoverage(data) {
|
|
coverageLayer.clearLayers();
|
|
if (!data || !Array.isArray(data)) {
|
|
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);
|
|
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]], {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
return;
|
|
}
|
|
if (visible) {
|
|
if (!map.hasLayer(coverageLayer)) {
|
|
coverageLayer.addTo(map);
|
|
}
|
|
if (!coverageData) {
|
|
fetchCoverageData().then(data => {
|
|
if (data && Array.isArray(data)) {
|
|
coverageData = data;
|
|
if (data.length === 0) {
|
|
reportError('Coverage database appears to be empty. Add coverage data to your coverage map server.');
|
|
}
|
|
renderCoverage(data);
|
|
} else {
|
|
reportError('Coverage API returned invalid data format');
|
|
}
|
|
});
|
|
} else {
|
|
renderCoverage(coverageData);
|
|
}
|
|
} else {
|
|
if (map.hasLayer(coverageLayer)) {
|
|
map.removeLayer(coverageLayer);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 (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 (peersActive && !map.hasLayer(peerLayer)) {
|
|
peerLayer.addTo(map);
|
|
}
|
|
if (peersActive && peersData) {
|
|
renderPeerLines(
|
|
{ lat: peersData.lat, lon: peersData.lon },
|
|
peersData.incoming || [],
|
|
peersData.outgoing || []
|
|
);
|
|
}
|
|
} 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(historyLayer)) {
|
|
map.removeLayer(historyLayer);
|
|
clearHistoryLayer();
|
|
}
|
|
if (heatLayer && map.hasLayer(heatLayer)) {
|
|
map.removeLayer(heatLayer);
|
|
}
|
|
if (coverageLayer && map.hasLayer(coverageLayer)) {
|
|
map.removeLayer(coverageLayer);
|
|
}
|
|
if (map.hasLayer(peerLayer)) {
|
|
map.removeLayer(peerLayer);
|
|
}
|
|
} else if (map.hasLayer(trailLayer)) {
|
|
map.removeLayer(trailLayer);
|
|
} else {
|
|
if (map.hasLayer(routeLayer)) {
|
|
map.removeLayer(routeLayer);
|
|
}
|
|
if (map.hasLayer(historyLayer)) {
|
|
map.removeLayer(historyLayer);
|
|
clearHistoryLayer();
|
|
}
|
|
if (heatLayer && map.hasLayer(heatLayer)) {
|
|
map.removeLayer(heatLayer);
|
|
}
|
|
if (map.hasLayer(peerLayer)) {
|
|
map.removeLayer(peerLayer);
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
updateHistoryPanelVisibility();
|
|
layoutSidePanels();
|
|
}
|
|
|
|
function setHistoryVisible(visible) {
|
|
historyVisible = visible;
|
|
if (visible) {
|
|
historyPanelHidden = 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();
|
|
}
|
|
}
|
|
|
|
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';
|
|
}
|
|
|
|
if (routeDetailsTitle) {
|
|
routeDetailsTitle.textContent = `Route: ${meta.id.slice(0, 8)}... (${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);
|
|
|
|
// Detect direction and normalize to Sender -> Receiver
|
|
// But we only have coordinates in points.
|
|
// We can try to match points[0] to receiver or origin deviceData logic?
|
|
// Heuristic: If we are the observer, and it's an inbound packet,
|
|
// usually points[0] is US (Receiver).
|
|
// Let's assume points are [P0, P1, ..., PN]
|
|
// We want to display [Sender, Hop1, Hop2, ..., Receiver]
|
|
|
|
// Clone points to avoid mutating meta
|
|
let displayPoints = meta.points.map((pt, i) => ({ ...pt, originalIndex: i }));
|
|
|
|
// Check if points[0] is closer to Receiver than points[last]?
|
|
// Or use the heuristic that MeshCore usually sends [Receiver, ..., Sender] for inbound?
|
|
// Let's rely on finding names.
|
|
|
|
// First, populate names for all points to help query
|
|
const populateName = (pt) => {
|
|
let name = 'Unknown';
|
|
let id = null;
|
|
for (const [did, d] of deviceData.entries()) {
|
|
if (Math.abs(d.lat - pt.lat) < 0.0001 && Math.abs(d.lon - pt.lon) < 0.0001) {
|
|
name = d.name || did;
|
|
id = did;
|
|
if (d.name && d.name !== did) name = d.name;
|
|
break;
|
|
}
|
|
}
|
|
pt.resolvedName = name;
|
|
pt.resolvedId = id;
|
|
};
|
|
displayPoints.forEach(populateName);
|
|
|
|
// Heuristic: If index 0 is the Receiver (e.g. "Observer (You)" candidates), we reverse.
|
|
// Or if index 0 label matches meta.receiver_label.
|
|
// We assume if it's reversed, we want to flip it.
|
|
|
|
// Helper to check hex
|
|
const isHexId = (s) => /^[0-9a-fA-F]{2,}$/.test(s) || /^[0-9]+$/.test(s);
|
|
|
|
let isReversed = false;
|
|
|
|
// Check P0
|
|
const p0Name = displayPoints[0].resolvedName;
|
|
const pLastName = displayPoints[displayPoints.length - 1].resolvedName;
|
|
|
|
// If P0 is likely receiver (Observer) or matches receiver label
|
|
if (p0Name === 'Unknown' || isHexId(p0Name) || (meta.receiver_label && p0Name === meta.receiver_label)) {
|
|
// Strong indenticator: P0 is Receiver.
|
|
// Confirm by checking if Last matches Origin?
|
|
// Or just assume inbound routes need/have P0=Receiver.
|
|
// Let's check against meta.receiver_id if available?
|
|
// deviceData keys are string IDs.
|
|
if (displayPoints[0].resolvedId && displayPoints[0].resolvedId === meta.receiver_id) {
|
|
isReversed = true;
|
|
} else if (meta.receiver_label && (p0Name === meta.receiver_label || p0Name === 'Unknown')) {
|
|
// Loose matching
|
|
isReversed = true;
|
|
} else if (isHexId(p0Name) && !isHexId(pLastName)) {
|
|
// If P0 is an ID (likely local/unknown) and PLast is a Name (likely remote sender),
|
|
// it's likely reversed (Receiver first).
|
|
isReversed = true;
|
|
}
|
|
}
|
|
|
|
if (isReversed) {
|
|
displayPoints.reverse();
|
|
}
|
|
|
|
displayPoints.forEach((pt, displayIdx) => {
|
|
const row = document.createElement('div');
|
|
row.className = 'hop-row';
|
|
|
|
let paramName = pt.resolvedName;
|
|
const isFirst = displayIdx === 0;
|
|
const isLast = displayIdx === displayPoints.length - 1;
|
|
|
|
// Refine Names based on position
|
|
if (isFirst) {
|
|
// Sender / Origin
|
|
if (meta.origin_label && (paramName === 'Unknown' || isHexId(paramName))) {
|
|
paramName = meta.origin_label;
|
|
}
|
|
if (isHexId(paramName) && meta.origin_id && !isHexId(meta.origin_id)) {
|
|
// prefer label if name is hex
|
|
}
|
|
} else if (isLast) {
|
|
// Receiver / Dest
|
|
if (meta.receiver_label && (paramName === 'Unknown' || isHexId(paramName))) {
|
|
// Don't use packet
|
|
if (!meta.receiver_label.toLowerCase().includes('packet')) {
|
|
paramName = meta.receiver_label;
|
|
}
|
|
}
|
|
// Check current resolved name for "Observer" heuristic
|
|
// If resolvedId matches local ID? We don't have local ID easily unless we check knowns.
|
|
// But if paramName is still hex or Unknown, and it's last (Receiver), call it Observer?
|
|
// BUT USER SAID: "a8 is ... non-repeater node".
|
|
// If we reversed correctly, P_Last is 'a8'.
|
|
// This is the Observer/Receiver.
|
|
if (paramName === 'Unknown' || isHexId(paramName)) {
|
|
paramName = `Observer (${paramName})`;
|
|
}
|
|
} else {
|
|
// Hops
|
|
if (paramName === 'Unknown' || isHexId(paramName)) {
|
|
paramName = `Repeater ${displayIdx} (${paramName})`;
|
|
}
|
|
}
|
|
// Check packet label again
|
|
if (paramName && paramName.toLowerCase().includes('packet')) {
|
|
paramName = 'Unknown Repeater';
|
|
}
|
|
|
|
const distInfo = Number.isFinite(pt.hop_distance_m)
|
|
? formatDistanceUnits(pt.hop_distance_m)
|
|
: '';
|
|
|
|
// Use originalIndex to access hashes directly to avoid shifts.
|
|
const originalIdx = (pt.originalIndex !== undefined) ? pt.originalIndex : displayIdx;
|
|
let idInfo = '';
|
|
if (meta.hashes && meta.hashes[originalIdx]) {
|
|
const h = meta.hashes[originalIdx];
|
|
const label = hashFirstByteLabel(h);
|
|
if (label) {
|
|
const parts = label.split(' ');
|
|
idInfo = `ID: ${parts[0]}`;
|
|
}
|
|
} else if (pt.hop_first_byte) {
|
|
idInfo = `ID: ${pt.hop_first_byte}`;
|
|
}
|
|
|
|
const metaInfo = [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) => {
|
|
if (peer.lat == null || peer.lon == null) return;
|
|
const line = L.polyline([originLatLng, [peer.lat, peer.lon]], {
|
|
color,
|
|
weight: 3,
|
|
opacity: 0.85,
|
|
dashArray: dash
|
|
}).addTo(peerLayer);
|
|
peerLines.set(peer.peer_id, line);
|
|
};
|
|
incoming.forEach(peer => drawLine(peer, '#38bdf8', '6 8'));
|
|
outgoing.forEach(peer => drawLine(peer, '#a855f7', '2 6'));
|
|
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;
|
|
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();
|
|
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) {
|
|
setPeersStatus('Peer lookup failed.');
|
|
if (peersMeta) peersMeta.textContent = '';
|
|
renderPeerList(peersIn, [], 0, 'incoming');
|
|
renderPeerList(peersOut, [], 0, 'outgoing');
|
|
peersData = null;
|
|
clearPeerLines();
|
|
}
|
|
}
|
|
|
|
function clearPeers() {
|
|
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) {
|
|
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 lastSeen = d.mqtt_seen_ts || null;
|
|
if (!lastSeen) return false;
|
|
return (Date.now() / 1000 - 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 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 (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, 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 clearLos() {
|
|
const keepA = losKeepAInput && losKeepAInput.checked;
|
|
const hasA = losPoints.length > 0;
|
|
const canKeepA = keepA && losPoints.length > 1 && hasA;
|
|
const keptPoint = canKeepA ? losPoints[0] : null;
|
|
losPoints = [];
|
|
losLine = 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 = [];
|
|
if (keptPoint) {
|
|
losPoints = [keptPoint];
|
|
const marker = createLosPointMarker(keptPoint, 0);
|
|
losPointMarkers.push(marker);
|
|
setLosStatus('LOS: select second point');
|
|
}
|
|
}
|
|
|
|
function setLosActive(active) {
|
|
losActive = active;
|
|
const btn = document.getElementById('los-toggle');
|
|
if (btn) {
|
|
btn.classList.toggle('active', active);
|
|
btn.textContent = active ? 'LOS: click 2 points' : 'LOS tool';
|
|
}
|
|
if (losLegendGroup) {
|
|
losLegendGroup.classList.toggle('active', active);
|
|
}
|
|
if (losPanel) {
|
|
losPanel.classList.toggle('active', active);
|
|
}
|
|
if (!active) {
|
|
clearLos();
|
|
} else {
|
|
losLocked = false;
|
|
setLosStatus('LOS: select first point (Shift+click or long-press nodes)');
|
|
}
|
|
layoutSidePanels();
|
|
}
|
|
|
|
function renderLosProfile(profile, blocked) {
|
|
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);
|
|
let minElev = Infinity;
|
|
let maxElev = -Infinity;
|
|
profile.forEach(item => {
|
|
const terrain = Number(item[1]);
|
|
const los = Number(item[2]);
|
|
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.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 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-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
|
|
};
|
|
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';
|
|
let status = `LOS: ${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');
|
|
}
|
|
losProfileTooltip.hidden = false;
|
|
losProfileTooltip.textContent = `Distance ${formatDistanceMeters(clampedDistance)} • Terrain ${formatElevationMeters(terrain)} • LOS ${formatElevationMeters(losLineValue)}`;
|
|
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) {
|
|
if (!losProfileMeta || !losPoints || losPoints.length < 2) return;
|
|
const total = losProfileMeta.totalDistance;
|
|
const clamped = Math.min(Math.max(distanceMeters, 0), total);
|
|
const t = total > 0 ? (clamped / total) : 0;
|
|
const start = losPoints[0];
|
|
const end = losPoints[1];
|
|
const lat = start.lat + (end.lat - start.lat) * t;
|
|
const lon = start.lng + (end.lng - start.lng) * t;
|
|
const label = updateLosPeakHighlight(clamped);
|
|
const tooltip = label
|
|
? `${label} • ${formatDistanceMeters(clamped)}`
|
|
: `LOS point • ${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) {
|
|
if (!losPoints || losPoints.length < 2 || distanceMeters == null || !losProfileMeta) return;
|
|
const total = losProfileMeta.totalDistance || 0;
|
|
const t = total > 0 ? Math.min(Math.max(distanceMeters / total, 0), 1) : 0;
|
|
const start = losPoints[0];
|
|
const end = losPoints[1];
|
|
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) {
|
|
if (!latlng || losPoints.length < 2) return;
|
|
if (!losProfileMeta || !losProfileData || losProfileData.length < 2) return;
|
|
const start = losPoints[0];
|
|
const end = losPoints[1];
|
|
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);
|
|
updateLosProfileAtDistance(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;
|
|
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;
|
|
lastLosStatusMeta = {
|
|
distance_m: Number.isFinite(meters) ? meters : null,
|
|
blocked,
|
|
obstruction_m: data.max_obstruction_m,
|
|
suggested: false,
|
|
suggested_clear: false
|
|
};
|
|
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' });
|
|
lastLosStatusMeta.suggested = true;
|
|
lastLosStatusMeta.suggested_clear = !!s.clear;
|
|
}
|
|
setLosStatus(buildLosStatus(lastLosStatusMeta));
|
|
renderLosProfile(data.profile, blocked);
|
|
if (losLine) {
|
|
losLine.setStyle({
|
|
color: blocked ? '#ef4444' : '#22c55e',
|
|
weight: 5,
|
|
opacity: 0.9,
|
|
dashArray: blocked ? '4 10' : null
|
|
});
|
|
}
|
|
clearLosProfileHover();
|
|
return true;
|
|
}
|
|
|
|
async function runLosCheckClient(a, b, options = {}) {
|
|
const heightA = Number.isFinite(losHeightA) ? losHeightA : 0;
|
|
const heightB = Number.isFinite(losHeightB) ? losHeightB : 0;
|
|
const distanceMeters = haversineMeters(a.lat, a.lng, b.lat, b.lng);
|
|
if (distanceMeters <= 0) {
|
|
return { ok: false, error: 'invalid_distance' };
|
|
}
|
|
const points = sampleLosPoints(a.lat, a.lng, b.lat, b.lng);
|
|
const elevationResponse = await fetchElevations(points, options);
|
|
if (!elevationResponse.ok) {
|
|
return {
|
|
ok: false,
|
|
error: elevationResponse.error || 'elevation_failed',
|
|
rateLimited: elevationResponse.rateLimited === true
|
|
};
|
|
}
|
|
const elevations = elevationResponse.elevations || [];
|
|
if (elevations.length !== points.length) {
|
|
return { ok: false, error: 'elevation_failed:unexpected_length' };
|
|
}
|
|
if (!elevationResponse.approximate || !losLastElevations) {
|
|
losLastElevations = elevations.slice();
|
|
losLastElevationCount = elevations.length;
|
|
}
|
|
const adjusted = elevations.slice();
|
|
const startElev = elevations[0] + heightA;
|
|
const endElev = elevations[elevations.length - 1] + heightB;
|
|
adjusted[0] = startElev;
|
|
adjusted[adjusted.length - 1] = endElev;
|
|
const maxObstruction = losMaxObstruction(points, adjusted, 0, points.length - 1);
|
|
const blocked = maxObstruction > 0;
|
|
const suggestion = blocked ? findLosSuggestion(points, adjusted) : null;
|
|
const round2 = (value) => Number(value.toFixed(2));
|
|
const profile = points.map((point, idx) => {
|
|
const lineElev = startElev + (endElev - startElev) * point.t;
|
|
return [
|
|
round2(distanceMeters * point.t),
|
|
round2(elevations[idx]),
|
|
round2(lineElev)
|
|
];
|
|
});
|
|
return {
|
|
ok: true,
|
|
blocked,
|
|
max_obstruction_m: round2(maxObstruction),
|
|
distance_m: round2(distanceMeters),
|
|
profile,
|
|
peaks: findLosPeaks(points, elevations, distanceMeters),
|
|
suggested: suggestion,
|
|
approximate: elevationResponse.approximate === true,
|
|
rateLimited: elevationResponse.rateLimited === true
|
|
};
|
|
}
|
|
|
|
async function fetchLosServerResult(a, b) {
|
|
const heightA = Number.isFinite(losHeightA) ? losHeightA : 0;
|
|
const heightB = Number.isFinite(losHeightB) ? losHeightB : 0;
|
|
const params = new URLSearchParams({
|
|
lat1: a.lat.toFixed(6),
|
|
lon1: a.lng.toFixed(6),
|
|
lat2: b.lat.toFixed(6),
|
|
lon2: b.lng.toFixed(6),
|
|
h1: heightA.toFixed(2),
|
|
h2: heightB.toFixed(2),
|
|
});
|
|
try {
|
|
const res = await fetch(`/los?${params.toString()}`);
|
|
const data = await res.json();
|
|
if (!data.ok) {
|
|
return { ok: false, error: data.error || 'failed' };
|
|
}
|
|
return data;
|
|
} catch (err) {
|
|
return { ok: false, error: 'los_server_failed' };
|
|
}
|
|
}
|
|
|
|
function resetPropagationRaster() {
|
|
if (propagationRaster) {
|
|
propagationLayer.removeLayer(propagationRaster);
|
|
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);
|
|
}
|
|
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 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 || !opacityInput || !modelSelect || !terrainInput || !txAglInput || !rxAglInput || !txMslInput || !rxMslInput || !minRxInput || !autoRangeInput || !fadeMarginInput || !webGpuInput || !autoResInput || !maxCellsInput || !gridInput || !sampleInput || !rangeFactorInput) {
|
|
return null;
|
|
}
|
|
const txPower = Number(txInput.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,
|
|
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 + PROP_DEFAULTS.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 + PROP_DEFAULTS.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 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 title = d.name
|
|
? `<span class="popup-title">${d.name}</span><span class="popup-id">${deviceLabel}</span>`
|
|
: `<span class="popup-title popup-id">${deviceLabel}</span>`;
|
|
const role = resolveRole(d);
|
|
const roleLabel = role === 'unknown' ? '' : role.charAt(0).toUpperCase() + role.slice(1);
|
|
const mqttLabel = isMqttOnline(d) ? 'Online' : 'Offline';
|
|
return `
|
|
${title}
|
|
<span class="small">
|
|
${roleLabel ? `Role: ${roleLabel}<br/>` : ``}
|
|
Location: ${d.lat.toFixed(6)}, ${d.lon.toFixed(6)}<br/>
|
|
Last Contact: ${lastContact}<br/>
|
|
MQTT: ${mqttLabel}<br/>
|
|
${d.rssi != null ? `RSSI: ${d.rssi}<br/>` : ``}
|
|
${d.snr != null ? `SNR: ${d.snr}<br/>` : ``}
|
|
</span>
|
|
`;
|
|
}
|
|
|
|
function upsertDevice(d, trail) {
|
|
const id = d.device_id;
|
|
const latlng = [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).addTo(markerLayer);
|
|
m.bindPopup(makePopup(d), {
|
|
maxWidth: 260,
|
|
maxHeight: 320,
|
|
autoPan: false,
|
|
keepInView: false
|
|
});
|
|
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);
|
|
} else {
|
|
const m = markers.get(id);
|
|
m.setLatLng(latlng);
|
|
m.setPopupContent(makePopup(d));
|
|
if (m.setStyle) m.setStyle(style);
|
|
updateMarkerLabel(m, d);
|
|
}
|
|
|
|
// 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, {
|
|
color: '#38bdf8',
|
|
weight: 3,
|
|
opacity: 0.85,
|
|
className: 'trail-animated'
|
|
}).addTo(trailLayer);
|
|
polylines.set(id, pl);
|
|
} else {
|
|
const pl = polylines.get(id);
|
|
pl.setLatLngs(points);
|
|
if (pl.setStyle) {
|
|
pl.setStyle({ color: '#38bdf8', weight: 3, opacity: 0.85 });
|
|
}
|
|
}
|
|
} else if (polylines.has(id)) {
|
|
trailLayer.removeLayer(polylines.get(id));
|
|
polylines.delete(id);
|
|
}
|
|
|
|
setStats();
|
|
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);
|
|
});
|
|
setStats();
|
|
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));
|
|
});
|
|
setStats();
|
|
}
|
|
|
|
function removeRoutes(ids) {
|
|
ids.forEach(id => {
|
|
const entry = routeLines.get(id);
|
|
if (!entry) return;
|
|
if (entry.timeout) clearTimeout(entry.timeout);
|
|
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);
|
|
}
|
|
});
|
|
setStats();
|
|
}
|
|
|
|
function clearRoutes() {
|
|
routeLines.forEach(entry => {
|
|
if (entry.timeout) clearTimeout(entry.timeout);
|
|
routeLayer.removeLayer(entry.line);
|
|
});
|
|
routeLines.clear();
|
|
setStats();
|
|
}
|
|
|
|
function clearHistoryLayer() {
|
|
historyLines.forEach(entry => {
|
|
if (!entry || !entry.line) return;
|
|
historyLayer.removeLayer(entry.line);
|
|
});
|
|
historyLines.clear();
|
|
refreshHistoryStyles();
|
|
setStats();
|
|
}
|
|
|
|
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();
|
|
setStats();
|
|
}
|
|
|
|
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, { 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();
|
|
setStats();
|
|
}
|
|
|
|
function upsertHistoryEdge(edge) {
|
|
const id = historyEdgeId(edge);
|
|
if (!id) return;
|
|
const edgeData = { ...edge, id };
|
|
historyCache.set(id, edgeData);
|
|
if (!historyVisible || !nodesVisible) {
|
|
setStats();
|
|
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 hashFirstByteLabel(hash) {
|
|
if (!hash) return null;
|
|
const text = String(hash).trim();
|
|
if (!text) return null;
|
|
const maybeHex = text.slice(0, 2);
|
|
const hexVal = Number.parseInt(maybeHex, 16);
|
|
if (!Number.isNaN(hexVal)) {
|
|
return `0x${maybeHex.toUpperCase()} (${hexVal})`;
|
|
}
|
|
const firstChar = text.charCodeAt(0);
|
|
if (Number.isFinite(firstChar)) {
|
|
return `0x${firstChar.toString(16).padStart(2, '0').toUpperCase()} (${firstChar})`;
|
|
}
|
|
return text.slice(0, 4);
|
|
}
|
|
|
|
function buildRouteLogMeta(route) {
|
|
if (!route || !Array.isArray(route.points)) return null;
|
|
const hopCount = Math.max(0, route.points.length - 1);
|
|
const distanceMeters = computeRouteDistanceMeters(route.points);
|
|
const expiresSeconds = Number(route.expires_at);
|
|
const expiresInSeconds = Number.isFinite(expiresSeconds)
|
|
? (expiresSeconds - Date.now() / 1000)
|
|
: null;
|
|
let cumulative = 0;
|
|
const hashes = Array.isArray(route.hashes) ? route.hashes : null;
|
|
const pointRows = route.points.map((pt, idx) => {
|
|
const lat = Number(pt[0]);
|
|
const lon = Number(pt[1]);
|
|
let hopDistance = null;
|
|
if (idx > 0 && Number.isFinite(lat) && Number.isFinite(lon)) {
|
|
const prev = route.points[idx - 1];
|
|
const prevLat = Number(prev[0]);
|
|
const prevLon = Number(prev[1]);
|
|
if (Number.isFinite(prevLat) && Number.isFinite(prevLon)) {
|
|
hopDistance = haversineMeters(prevLat, prevLon, lat, lon);
|
|
if (Number.isFinite(hopDistance)) {
|
|
cumulative += hopDistance;
|
|
}
|
|
}
|
|
}
|
|
const hopHash = hashes && idx > 0 ? hashes[idx - 1] : null;
|
|
return {
|
|
index: idx,
|
|
lat,
|
|
lon,
|
|
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_first_byte: hopHash ? hashFirstByteLabel(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),
|
|
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,
|
|
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_first_byte: pt.hop_first_byte || 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 isFanout = routeMode === 'fanout';
|
|
const payloadType = Number(r.payload_type);
|
|
const isAdvert = payloadType === 4;
|
|
const isTrace = payloadType === 8 || payloadType === 9;
|
|
const isMessage = payloadType === 2 || payloadType === 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';
|
|
}
|
|
|
|
const routeMeta = buildRouteLogMeta({ ...r, id, points, hashes: r.hashes });
|
|
let entry = routeLines.get(id);
|
|
if (!entry) {
|
|
const line = L.polyline(points, style).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;
|
|
const lineEl = entry.line.getElement();
|
|
if (lineEl) {
|
|
lineEl.classList.add('route-animated');
|
|
}
|
|
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) - Date.now());
|
|
entry.timeout = setTimeout(() => removeRoutes([id]), ms);
|
|
}
|
|
|
|
if (!skipHeat) {
|
|
addHeatPoints(points, r.ts, r.payload_type);
|
|
}
|
|
if (hopsVisible) {
|
|
renderHopMarkers(id, entry.meta);
|
|
}
|
|
setStats();
|
|
}
|
|
|
|
async function initialSnapshot() {
|
|
try {
|
|
const res = await fetch(withToken('/snapshot'), { headers: tokenHeaders() });
|
|
const snap = await res.json();
|
|
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));
|
|
}
|
|
if (snap.history_window_seconds != null) {
|
|
historyWindowSeconds = Number(snap.history_window_seconds);
|
|
updateHistoryWindowLabel(historyWindowSeconds);
|
|
}
|
|
if (snap.update) {
|
|
setUpdateBanner(snap.update);
|
|
}
|
|
setStats();
|
|
} catch (e) {
|
|
console.warn("snapshot failed", e);
|
|
}
|
|
}
|
|
|
|
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
|
|
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));
|
|
}
|
|
if (msg.history_window_seconds != null) {
|
|
historyWindowSeconds = Number(msg.history_window_seconds);
|
|
updateHistoryWindowLabel(historyWindowSeconds);
|
|
}
|
|
if (msg.update) {
|
|
setUpdateBanner(msg.update);
|
|
}
|
|
setStats();
|
|
return;
|
|
}
|
|
|
|
if (msg.type === "update") {
|
|
upsertDevice(msg.device, msg.trail);
|
|
return;
|
|
}
|
|
|
|
if (msg.type === "device_seen") {
|
|
const id = msg.device_id;
|
|
const d = deviceData.get(id);
|
|
if (d) {
|
|
if (msg.last_seen_ts) d.last_seen_ts = msg.last_seen_ts;
|
|
if (msg.mqtt_seen_ts) d.mqtt_seen_ts = msg.mqtt_seen_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);
|
|
}
|
|
setStats();
|
|
}
|
|
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));
|
|
setStats();
|
|
return;
|
|
}
|
|
|
|
if (msg.type === "history_edges_remove") {
|
|
removeHistoryEdges(msg.edge_ids || []);
|
|
return;
|
|
}
|
|
|
|
if (msg.type === "stale") {
|
|
removeDevices(msg.device_ids || []);
|
|
return;
|
|
}
|
|
};
|
|
}
|
|
|
|
async function runLosCheck(options = {}) {
|
|
if (losPoints.length < 2) return;
|
|
const [a, b] = losPoints;
|
|
losComputeLast = Date.now();
|
|
const token = ++losComputeToken;
|
|
const allowNetwork = options.allowNetwork !== false;
|
|
const allowApprox = options.allowApprox === true;
|
|
setLosStatus('LOS: calculating...');
|
|
try {
|
|
if (losLine) {
|
|
losLine.setStyle({ color: '#9ca3af', weight: 4, opacity: 0.8, dashArray: '6 10' });
|
|
}
|
|
const clientResult = await runLosCheckClient(a, b, {
|
|
allowNetwork,
|
|
allowApprox,
|
|
forceNetwork: options.forceNetwork === true
|
|
});
|
|
if (token !== losComputeToken) return;
|
|
if (clientResult.ok && applyLosResult(clientResult)) {
|
|
return;
|
|
}
|
|
if (clientResult.error === 'invalid_distance') {
|
|
lastLosStatusMeta = null;
|
|
setLosStatus('LOS: invalid distance');
|
|
return;
|
|
}
|
|
if (!allowNetwork || clientResult.rateLimited) {
|
|
if (clientResult.error === 'elevation_cache_miss') {
|
|
setLosStatus('LOS: waiting for elevation data');
|
|
} else if (clientResult.rateLimited) {
|
|
setLosStatus('LOS: rate limited, using cached terrain');
|
|
} else {
|
|
setLosStatus(`LOS: ${clientResult.error || 'error'}`);
|
|
}
|
|
return;
|
|
}
|
|
const serverResult = await fetchLosServerResult(a, b);
|
|
if (token !== losComputeToken) return;
|
|
if (serverResult.ok && applyLosResult(serverResult)) {
|
|
return;
|
|
}
|
|
lastLosStatusMeta = null;
|
|
setLosStatus(`LOS: ${serverResult.error || clientResult.error || 'error'}`);
|
|
if (losLine) {
|
|
losLine.setStyle({ color: '#9ca3af', weight: 4, opacity: 0.8, dashArray: '6 10' });
|
|
}
|
|
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 (losPoints.length < 2) return;
|
|
losPendingOptions = options;
|
|
if (force) {
|
|
if (losComputeTimer) {
|
|
clearTimeout(losComputeTimer);
|
|
losComputeTimer = null;
|
|
}
|
|
runLosCheck(options);
|
|
return;
|
|
}
|
|
const now = Date.now();
|
|
const elapsed = now - losComputeLast;
|
|
const delay = Math.max(0, LOS_COMPUTE_THROTTLE_MS - elapsed);
|
|
if (delay === 0) {
|
|
runLosCheck(options);
|
|
return;
|
|
}
|
|
if (losComputeTimer) return;
|
|
losComputeTimer = setTimeout(() => {
|
|
losComputeTimer = null;
|
|
runLosCheck(losPendingOptions || {});
|
|
}, delay);
|
|
}
|
|
|
|
initialSnapshot();
|
|
connectWS();
|
|
setStats();
|
|
setInterval(refreshHeatLayer, 15000);
|
|
setInterval(refreshOnlineMarkers, 30000);
|
|
if ('serviceWorker' in navigator) {
|
|
window.addEventListener('load', () => {
|
|
navigator.serviceWorker.register('/sw.js').catch(() => { });
|
|
});
|
|
}
|
|
|
|
if (routeDetailsHide) {
|
|
routeDetailsHide.addEventListener('click', () => {
|
|
if (routeDetailsPanel) {
|
|
routeDetailsPanel.hidden = true;
|
|
routeDetailsPanel.classList.remove('active');
|
|
routeDetailsPanel.style.display = 'none';
|
|
}
|
|
activeRouteDetailsMeta = null;
|
|
activeRouteDetailsId = null;
|
|
layoutSidePanels();
|
|
});
|
|
}
|
|
|
|
const hopsToggle = document.getElementById('hops-toggle');
|
|
if (hopsToggle) {
|
|
hopsToggle.addEventListener('click', () => {
|
|
setHopsVisible(!hopsVisible);
|
|
});
|
|
}
|
|
|
|
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');
|
|
}
|
|
}
|
|
|
|
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 (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('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);
|
|
}
|
|
}
|
|
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) => {
|
|
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 = () => {
|
|
losHeightA = parseLosHeightValue(losHeightAInput);
|
|
losHeightB = parseLosHeightValue(losHeightBInput);
|
|
try {
|
|
localStorage.setItem('meshmapLosHeightA', String(losHeightA));
|
|
localStorage.setItem('meshmapLosHeightB', String(losHeightB));
|
|
} catch (err) {
|
|
// ignore storage failures
|
|
}
|
|
if (losPoints.length === 2) {
|
|
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 updateLosLineFromPoints() {
|
|
if (!losLine || losPoints.length < 2) return;
|
|
losLine.setLatLngs([losPoints[0], losPoints[1]]);
|
|
}
|
|
|
|
function updateLosPointPosition(index, latlng) {
|
|
if (index == null || !losPoints[index]) return;
|
|
losPoints[index] = latlng;
|
|
updateLosLineFromPoints();
|
|
if (losLine && losPoints.length >= 2) {
|
|
lastLosStatusMeta = null;
|
|
losLine.setStyle({ color: '#9ca3af', weight: 4, opacity: 0.8, dashArray: '6 10' });
|
|
setLosStatus('LOS: calculating...');
|
|
}
|
|
}
|
|
|
|
function setLosSelectedPoint(index) {
|
|
if (!Number.isInteger(index) || index < 0 || index >= losPointMarkers.length) {
|
|
losSelectedPointIndex = null;
|
|
} else {
|
|
losSelectedPointIndex = index;
|
|
}
|
|
losPointMarkers.forEach((pointMarker, idx) => {
|
|
const el = pointMarker && pointMarker.getElement ? pointMarker.getElement() : null;
|
|
if (!el) return;
|
|
el.classList.toggle('selected', losSelectedPointIndex === idx);
|
|
});
|
|
}
|
|
|
|
function createLosPointMarker(latlng, index) {
|
|
const marker = L.marker(latlng, {
|
|
icon: losPointIcon,
|
|
draggable: true,
|
|
autoPan: false,
|
|
zIndexOffset: 450
|
|
}).addTo(losLayer);
|
|
marker.__losIndex = index;
|
|
marker.on('click', (ev) => {
|
|
if (ev && ev.originalEvent) {
|
|
ev.originalEvent.preventDefault();
|
|
ev.originalEvent.stopPropagation();
|
|
}
|
|
if (typeof L !== 'undefined' && L.DomEvent) {
|
|
L.DomEvent.stop(ev);
|
|
}
|
|
setLosSelectedPoint(index);
|
|
setLosStatus(`LOS: selected point ${index === 0 ? 'A' : 'B'} (click map to move or drag point)`);
|
|
});
|
|
marker.on('dragstart', () => {
|
|
const el = marker.getElement();
|
|
if (el) el.classList.add('dragging');
|
|
setLosSelectedPoint(index);
|
|
losDragging = true;
|
|
clearLosProfileHover();
|
|
});
|
|
marker.on('drag', () => {
|
|
const next = marker.getLatLng();
|
|
updateLosPointPosition(index, next);
|
|
scheduleLosCheck(false, { allowNetwork: false, allowApprox: true });
|
|
});
|
|
marker.on('dragend', () => {
|
|
const el = marker.getElement();
|
|
if (el) el.classList.remove('dragging');
|
|
const next = marker.getLatLng();
|
|
updateLosPointPosition(index, next);
|
|
losDragging = false;
|
|
scheduleLosCheck(true, { allowNetwork: true, forceNetwork: true });
|
|
});
|
|
marker.on('add', () => setLosSelectedPoint(losSelectedPointIndex));
|
|
return marker;
|
|
}
|
|
|
|
function handleLosPoint(latlng) {
|
|
if (losLocked || losPoints.length >= 2) {
|
|
setLosStatus('LOS: Clear to start a new path');
|
|
return;
|
|
}
|
|
losPoints.push(latlng);
|
|
const marker = createLosPointMarker(latlng, losPoints.length - 1);
|
|
losPointMarkers.push(marker);
|
|
setLosSelectedPoint(losPoints.length - 1);
|
|
|
|
if (losPoints.length === 1) {
|
|
setLosStatus('LOS: select second point');
|
|
return;
|
|
}
|
|
if (losPoints.length === 2) {
|
|
losLocked = true;
|
|
losLine = L.polyline([losPoints[0], losPoints[1]], {
|
|
color: '#9ca3af',
|
|
weight: 4,
|
|
opacity: 0.8,
|
|
dashArray: '6 10'
|
|
}).addTo(losLayer);
|
|
losLine.on('mousemove', (ev) => {
|
|
if (ev && ev.latlng) {
|
|
updateLosProfileFromMap(ev.latlng);
|
|
}
|
|
});
|
|
losLine.on('mouseout', clearLosProfileHover);
|
|
scheduleLosCheck(true, { allowNetwork: true, forceNetwork: true });
|
|
}
|
|
}
|
|
|
|
if (losClearButton) {
|
|
losClearButton.addEventListener('click', () => {
|
|
clearLos();
|
|
if (losActive) {
|
|
if (!losKeepAInput || !losKeepAInput.checked || losPoints.length === 0) {
|
|
setLosStatus('LOS: select first point (Shift+click or long-press nodes)');
|
|
}
|
|
}
|
|
});
|
|
}
|
|
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);
|
|
});
|
|
}
|
|
if (historyHideButton) {
|
|
const hideHistoryPanel = (ev) => {
|
|
if (ev) {
|
|
if (ev.preventDefault) ev.preventDefault();
|
|
if (ev.stopPropagation) ev.stopPropagation();
|
|
if (typeof L !== 'undefined' && L.DomEvent) L.DomEvent.stop(ev);
|
|
}
|
|
setHistoryPanelHidden(true);
|
|
};
|
|
historyHideButton.addEventListener('click', hideHistoryPanel);
|
|
historyHideButton.addEventListener('pointerdown', hideHistoryPanel);
|
|
}
|
|
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;
|
|
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 propToggle = document.getElementById('prop-toggle');
|
|
const propTxInput = document.getElementById('prop-txpower');
|
|
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 (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('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);
|
|
}
|
|
updateLosPointPosition(idx, ev.latlng);
|
|
scheduleLosCheck(true, { allowNetwork: true, forceNetwork: true });
|
|
return;
|
|
}
|
|
handleLosPoint(ev.latlng);
|
|
return;
|
|
}
|
|
if (propagationActive) {
|
|
setPropagationOrigin(ev.latlng);
|
|
}
|
|
});
|