mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-03-28 17:42:48 +01:00
4392 lines
156 KiB
JavaScript
4392 lines
156 KiB
JavaScript
/*
|
|
* Copyright © 2025-26 l5yth & contributors
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
import { computeBoundingBox, computeBoundsForPoints, haversineDistanceKm } from './map-bounds.js';
|
|
import { createMapAutoFitController } from './map-auto-fit-controller.js';
|
|
import { resolveAutoFitBoundsConfig } from './map-auto-fit-settings.js';
|
|
import { attachNodeInfoRefreshToMarker, overlayToPopupNode } from './map-marker-node-info.js';
|
|
import { resolveLegendVisibility } from './map-legend-visibility.js';
|
|
import { createMapFocusHandler, DEFAULT_NODE_FOCUS_ZOOM } from './nodes-map-focus.js';
|
|
import { enhanceCoordinateCell } from './nodes-coordinate-links.js';
|
|
import { createShortInfoOverlayStack } from './short-info-overlay-manager.js';
|
|
import { createNodeDetailOverlayManager } from './node-detail-overlay.js';
|
|
import { refreshNodeInformation } from './node-details.js';
|
|
import { extractModemMetadata, formatLoraFrequencyMHz, formatModemDisplay } from './node-modem-metadata.js';
|
|
import {
|
|
TELEMETRY_FIELDS,
|
|
buildTelemetryDisplayEntries,
|
|
collectTelemetryMetrics,
|
|
fmtAlt,
|
|
fmtHumidity,
|
|
fmtPressure,
|
|
fmtTemperature,
|
|
fmtTx,
|
|
} from './short-info-telemetry.js';
|
|
import { renderSatsInViewBadge } from './short-info-satellites.js';
|
|
import { createMessageNodeHydrator } from './message-node-hydrator.js';
|
|
import {
|
|
extractChatMessageMetadata,
|
|
formatChatMessagePrefix,
|
|
formatNodeAnnouncementPrefix,
|
|
formatChatPresetTag
|
|
} from './chat-format.js';
|
|
import { initializeInstanceSelector } from './instance-selector.js';
|
|
import { initializeMobileMenu } from './mobile-menu.js';
|
|
import { MESSAGE_LIMIT, normaliseMessageLimit } from './message-limit.js';
|
|
import { CHAT_LOG_ENTRY_TYPES, buildChatTabModel, MAX_CHANNEL_INDEX } from './chat-log-tabs.js';
|
|
import { renderChatTabs } from './chat-tabs.js';
|
|
import { formatPositionHighlights, formatTelemetryHighlights } from './chat-log-highlights.js';
|
|
import { filterChatModel, normaliseChatFilterQuery } from './chat-search.js';
|
|
import { buildMessageBody, buildMessageIndex, resolveReplyPrefix } from './message-replies.js';
|
|
import {
|
|
SNAPSHOT_WINDOW,
|
|
aggregateNeighborSnapshots,
|
|
aggregateNodeSnapshots,
|
|
aggregatePositionSnapshots,
|
|
aggregateTelemetrySnapshots,
|
|
} from './snapshot-aggregator.js';
|
|
import { normalizeNodeCollection } from './node-snapshot-normalizer.js';
|
|
import { buildTraceSegments } from './trace-paths.js';
|
|
import {
|
|
getRoleColor,
|
|
getRoleKey,
|
|
getRoleRenderPriority,
|
|
normalizeRole,
|
|
roleColors,
|
|
roleRenderOrder,
|
|
} from './role-helpers.js';
|
|
|
|
/**
|
|
* Entry point for the interactive dashboard. Wires up event listeners,
|
|
* initializes the map, and triggers the first data refresh cycle.
|
|
*
|
|
* @param {{
|
|
* refreshMs: number,
|
|
* refreshIntervalSeconds: number,
|
|
* chatEnabled: boolean,
|
|
* channel: string,
|
|
* frequency: string,
|
|
* mapCenter: { lat: number, lon: number },
|
|
* mapZoom: number | null,
|
|
* maxDistanceKm: number,
|
|
* tileFilters: { light: string, dark: string }
|
|
* }} config Normalized application configuration.
|
|
* @returns {void}
|
|
*/
|
|
export function initializeApp(config) {
|
|
const statusEl = document.getElementById('status');
|
|
const fitBoundsEl = document.getElementById('fitBounds');
|
|
const autoRefreshEl = document.getElementById('autoRefresh');
|
|
const refreshBtn = document.getElementById('refreshBtn');
|
|
const filterInput = document.getElementById('filterInput');
|
|
const filterClearButton = document.getElementById('filterClear');
|
|
const themeToggle = document.getElementById('themeToggle');
|
|
const infoBtn = document.getElementById('infoBtn');
|
|
const infoOverlay = document.getElementById('infoOverlay');
|
|
const infoClose = document.getElementById('infoClose');
|
|
const infoDialog = infoOverlay ? infoOverlay.querySelector('.info-dialog') : null;
|
|
const shortInfoTemplate = document.getElementById('shortInfoOverlayTemplate');
|
|
const overlayStack = createShortInfoOverlayStack({ document, window, template: shortInfoTemplate });
|
|
const titleEl = document.querySelector('title');
|
|
const headerEl = document.querySelector('h1');
|
|
const headerTitleTextEl = headerEl ? headerEl.querySelector('.site-title-text') : null;
|
|
const chatEl = document.getElementById('chat');
|
|
const refreshInfo = document.getElementById('refreshInfo');
|
|
const instanceSelect = document.getElementById('instanceSelect');
|
|
const baseTitle = document.title;
|
|
const nodesTable = document.getElementById('nodes');
|
|
const sortButtons = nodesTable ? Array.from(nodesTable.querySelectorAll('thead .sort-button[data-sort-key]')) : [];
|
|
const infoOverlayHome = infoOverlay
|
|
? { parent: infoOverlay.parentNode, nextSibling: infoOverlay.nextSibling }
|
|
: null;
|
|
const bodyClassList = document.body ? document.body.classList : null;
|
|
const isPrivateMode = document.body && document.body.dataset
|
|
? String(document.body.dataset.privateMode).toLowerCase() === 'true'
|
|
: false;
|
|
const isDashboardView = bodyClassList ? bodyClassList.contains('view-dashboard') : false;
|
|
const isChatView = bodyClassList ? bodyClassList.contains('view-chat') : false;
|
|
const isMapView = bodyClassList ? bodyClassList.contains('view-map') : false;
|
|
const mapZoomOverride = Number.isFinite(config.mapZoom) ? Number(config.mapZoom) : null;
|
|
|
|
initializeMobileMenu({ documentObject: document, windowObject: window });
|
|
/**
|
|
* Column sorter configuration for the node table.
|
|
*
|
|
* Each entry provides a value extractor, comparator, and optional
|
|
* presence checker used to sort the rendered rows.
|
|
*
|
|
* @type {Record<string, {getValue: Function, compare: Function, hasValue?: Function, defaultDirection: 'asc' | 'desc'}>}
|
|
*/
|
|
const tableSorters = {
|
|
node_id: { getValue: n => n.node_id, compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
|
|
short_name: { getValue: n => n.short_name, compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
|
|
long_name: { getValue: n => n.long_name, compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
|
|
lora_freq: {
|
|
getValue: n => n.lora_freq ?? n.loraFreq ?? n.frequency,
|
|
compare: compareNumber,
|
|
hasValue: hasNumberValue,
|
|
defaultDirection: 'desc'
|
|
},
|
|
modem_preset: {
|
|
getValue: n => n.modem_preset ?? n.modemPreset,
|
|
compare: compareString,
|
|
hasValue: hasStringValue,
|
|
defaultDirection: 'asc'
|
|
},
|
|
last_heard: { getValue: n => n.last_heard, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'desc' },
|
|
role: { getValue: n => n.role, compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
|
|
hw_model: { getValue: n => n.hw_model, compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
|
|
battery_level: { getValue: n => n.battery_level, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'desc' },
|
|
voltage: { getValue: n => n.voltage, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'desc' },
|
|
uptime_seconds: { getValue: n => n.uptime_seconds, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'desc' },
|
|
channel_utilization: { getValue: n => n.channel_utilization, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'desc' },
|
|
air_util_tx: { getValue: n => n.air_util_tx, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'desc' },
|
|
temperature: { getValue: n => n.temperature, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'desc' },
|
|
relative_humidity: { getValue: n => n.relative_humidity, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'desc' },
|
|
barometric_pressure: { getValue: n => n.barometric_pressure, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'desc' },
|
|
latitude: { getValue: n => n.latitude, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'asc' },
|
|
longitude: { getValue: n => n.longitude, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'asc' },
|
|
altitude: { getValue: n => n.altitude, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'desc' },
|
|
position_time: { getValue: n => n.position_time, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'desc' }
|
|
};
|
|
/**
|
|
* Current table sorting state shared between refreshes.
|
|
* @type {{key: string, direction: 'asc' | 'desc'}}
|
|
*/
|
|
let sortState = {
|
|
key: 'last_heard',
|
|
direction: tableSorters.last_heard ? tableSorters.last_heard.defaultDirection : 'desc'
|
|
};
|
|
/** @type {Array<Object>} */
|
|
let allNodes = [];
|
|
/** @type {Array<Object>} */
|
|
let allNeighbors = [];
|
|
/** @type {Array<Object>} */
|
|
let allTraces = [];
|
|
/** @type {Array<Object>} */
|
|
let allMessages = [];
|
|
/** @type {Array<Object>} */
|
|
let allEncryptedMessages = [];
|
|
/** @type {Array<Object>} */
|
|
let allTelemetryEntries = [];
|
|
/** @type {Array<Object>} */
|
|
let allPositionEntries = [];
|
|
/** @type {Map<string, Object>} */
|
|
let nodesById = new Map();
|
|
let messagesById = new Map();
|
|
let nodesByNum = new Map();
|
|
const messageNodeHydrator = createMessageNodeHydrator({
|
|
fetchNodeById,
|
|
applyNodeFallback: applyNodeNameFallback,
|
|
logger: console,
|
|
});
|
|
const NODE_LIMIT = 1000;
|
|
const TRACE_LIMIT = 200;
|
|
const TRACE_MAX_AGE_SECONDS = 7 * 24 * 60 * 60;
|
|
const SNAPSHOT_LIMIT = SNAPSHOT_WINDOW;
|
|
const CHAT_LIMIT = MESSAGE_LIMIT;
|
|
const CHAT_RECENT_WINDOW_SECONDS = 7 * 24 * 60 * 60;
|
|
const REFRESH_MS = config.refreshMs;
|
|
const CHAT_ENABLED = Boolean(config.chatEnabled);
|
|
const instanceSelectorEnabled = Boolean(config.instancesFeatureEnabled);
|
|
if (refreshInfo) {
|
|
if (isDashboardView) {
|
|
refreshInfo.textContent = `${config.channel} (${config.frequency}) — active nodes: …`;
|
|
} else {
|
|
refreshInfo.textContent = '';
|
|
}
|
|
}
|
|
|
|
if (instanceSelectorEnabled && instanceSelect) {
|
|
void initializeInstanceSelector({
|
|
selectElement: instanceSelect,
|
|
instanceDomain: config.instanceDomain,
|
|
defaultLabel: 'Select region ...',
|
|
}).catch(error => {
|
|
console.warn('Instance selector initialisation failed', error);
|
|
});
|
|
}
|
|
|
|
/** @type {ReturnType<typeof setTimeout>|null} */
|
|
let refreshTimer = null;
|
|
|
|
/**
|
|
* Close any open short-info overlays that do not contain the provided anchor.
|
|
*
|
|
* The method preserves ancestor overlays that host nested short-name badges,
|
|
* ensuring context overlays (for example, neighbor listings) remain visible
|
|
* while unrelated overlays are dismissed.
|
|
*
|
|
* @param {?Element} anchorEl Short-name badge that triggered the interaction.
|
|
* @returns {void}
|
|
*/
|
|
function closeUnrelatedShortOverlays(anchorEl) {
|
|
if (!anchorEl) {
|
|
return;
|
|
}
|
|
const openOverlays = overlayStack.getOpenOverlays();
|
|
for (const entry of openOverlays) {
|
|
if (!entry || !entry.element || entry.element.contains(anchorEl)) {
|
|
continue;
|
|
}
|
|
overlayStack.close(entry.anchor);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Scroll the active chat tab panel to its most recent entry when the
|
|
* dedicated chat view is displayed.
|
|
*
|
|
* @returns {void}
|
|
*/
|
|
function scrollActiveChatPanelToBottom() {
|
|
if (!chatEl || !isChatView) {
|
|
return;
|
|
}
|
|
const activeTabId = chatEl.dataset?.activeTab;
|
|
if (!activeTabId) {
|
|
return;
|
|
}
|
|
const escapedId = cssEscape(activeTabId);
|
|
if (!escapedId) {
|
|
return;
|
|
}
|
|
const panel = chatEl.querySelector(`#chat-panel-${escapedId}`);
|
|
if (panel && typeof panel.scrollHeight === 'number' && typeof panel.scrollTop === 'number') {
|
|
panel.scrollTop = panel.scrollHeight;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Determine whether the provided value contains a non-empty string.
|
|
*
|
|
* @param {*} value Candidate value extracted from a node record.
|
|
* @returns {boolean} True when the value is a non-empty string.
|
|
*/
|
|
function hasStringValue(value) {
|
|
if (value == null) return false;
|
|
return String(value).trim().length > 0;
|
|
}
|
|
|
|
/**
|
|
* Determine whether the provided value can be interpreted as a finite number.
|
|
*
|
|
* @param {*} value Candidate value extracted from a node record.
|
|
* @returns {boolean} True when the value parses to a finite number.
|
|
*/
|
|
function hasNumberValue(value) {
|
|
if (value == null || value === '') return false;
|
|
const num = typeof value === 'number' ? value : Number(value);
|
|
return Number.isFinite(num);
|
|
}
|
|
|
|
/**
|
|
* Locale-aware comparator for string table values.
|
|
*
|
|
* @param {*} a First value.
|
|
* @param {*} b Second value.
|
|
* @returns {number} Comparator result compatible with ``Array.prototype.sort``.
|
|
*/
|
|
function compareString(a, b) {
|
|
const strA = (a == null ? '' : String(a)).trim();
|
|
const strB = (b == null ? '' : String(b)).trim();
|
|
const hasA = strA.length > 0;
|
|
const hasB = strB.length > 0;
|
|
if (!hasA && !hasB) return 0;
|
|
if (!hasA) return 1;
|
|
if (!hasB) return -1;
|
|
return strA.localeCompare(strB, undefined, { numeric: true, sensitivity: 'base' });
|
|
}
|
|
|
|
/**
|
|
* Comparator for numeric table values that tolerates string inputs.
|
|
*
|
|
* @param {*} a First value.
|
|
* @param {*} b Second value.
|
|
* @returns {number} Comparator result for ``Array.prototype.sort``.
|
|
*/
|
|
function compareNumber(a, b) {
|
|
const numA = typeof a === 'number' ? a : Number(a);
|
|
const numB = typeof b === 'number' ? b : Number(b);
|
|
const validA = Number.isFinite(numA);
|
|
const validB = Number.isFinite(numB);
|
|
if (validA && validB) {
|
|
if (numA === numB) return 0;
|
|
return numA < numB ? -1 : 1;
|
|
}
|
|
if (validA) return -1;
|
|
if (validB) return 1;
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Sort a list of nodes using the active table sorter configuration.
|
|
*
|
|
* @param {Array<Object>} nodes Collection of node entries.
|
|
* @returns {Array<Object>} Sorted shallow copy of ``nodes``.
|
|
*/
|
|
function sortNodes(nodes) {
|
|
if (!Array.isArray(nodes)) return [];
|
|
const config = tableSorters[sortState.key];
|
|
if (!config) return nodes.slice();
|
|
const dir = sortState.direction === 'asc' ? 1 : -1;
|
|
const getter = config.getValue;
|
|
const hasValue = config.hasValue;
|
|
const compare = config.compare;
|
|
const arr = nodes.slice();
|
|
arr.sort((a, b) => {
|
|
const valueA = getter(a);
|
|
const valueB = getter(b);
|
|
const presentA = hasValue ? hasValue(valueA) : valueA != null && valueA !== '';
|
|
const presentB = hasValue ? hasValue(valueB) : valueB != null && valueB !== '';
|
|
if (!presentA && !presentB) return 0;
|
|
if (!presentA) return 1;
|
|
if (!presentB) return -1;
|
|
const result = compare(valueA, valueB);
|
|
return result * dir;
|
|
});
|
|
return arr;
|
|
}
|
|
|
|
/**
|
|
* Synchronise the sort indicator icons and ARIA attributes in the table head.
|
|
*
|
|
* @returns {void}
|
|
*/
|
|
function updateSortIndicators() {
|
|
if (!nodesTable || !sortButtons.length) return;
|
|
nodesTable.querySelectorAll('thead th').forEach(th => th.removeAttribute('aria-sort'));
|
|
sortButtons.forEach(button => {
|
|
const indicator = button.querySelector('.sort-indicator');
|
|
if (indicator) indicator.textContent = '';
|
|
button.removeAttribute('data-sort-active');
|
|
button.setAttribute('aria-pressed', 'false');
|
|
const label = button.dataset.sortLabel || button.textContent.trim();
|
|
button.setAttribute('aria-label', `Sort by ${label}`);
|
|
});
|
|
const activeButton = sortButtons.find(button => button.dataset.sortKey === sortState.key);
|
|
if (!activeButton) return;
|
|
const indicator = activeButton.querySelector('.sort-indicator');
|
|
if (indicator) indicator.textContent = sortState.direction === 'asc' ? '▲' : '▼';
|
|
const th = activeButton.closest('th');
|
|
if (th) {
|
|
th.setAttribute('aria-sort', sortState.direction === 'asc' ? 'ascending' : 'descending');
|
|
}
|
|
activeButton.setAttribute('data-sort-active', 'true');
|
|
activeButton.setAttribute('aria-pressed', 'true');
|
|
const label = activeButton.dataset.sortLabel || activeButton.textContent.trim();
|
|
const directionLabel = sortState.direction === 'asc' ? 'ascending' : 'descending';
|
|
const nextDirection = sortState.direction === 'asc' ? 'descending' : 'ascending';
|
|
activeButton.setAttribute('aria-label', `${label}, sorted ${directionLabel}. Activate to sort ${nextDirection}.`);
|
|
}
|
|
|
|
if (sortButtons.length) {
|
|
sortButtons.forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
const key = button.dataset.sortKey;
|
|
if (!key) return;
|
|
if (sortState.key === key) {
|
|
sortState = { key, direction: sortState.direction === 'asc' ? 'desc' : 'asc' };
|
|
} else {
|
|
const config = tableSorters[key];
|
|
const dir = config && config.defaultDirection ? config.defaultDirection : 'asc';
|
|
sortState = { key, direction: dir };
|
|
}
|
|
applyFilter();
|
|
});
|
|
});
|
|
}
|
|
|
|
updateSortIndicators();
|
|
|
|
/**
|
|
* Restart the auto-refresh timer according to the user's preferences.
|
|
*
|
|
* @returns {void}
|
|
*/
|
|
function restartAutoRefresh() {
|
|
if (refreshTimer) {
|
|
clearInterval(refreshTimer);
|
|
refreshTimer = null;
|
|
}
|
|
if (autoRefreshEl && autoRefreshEl.checked) {
|
|
refreshTimer = setInterval(refresh, REFRESH_MS);
|
|
}
|
|
}
|
|
|
|
if (fitBoundsEl && mapZoomOverride !== null) {
|
|
fitBoundsEl.checked = false;
|
|
fitBoundsEl.disabled = true;
|
|
fitBoundsEl.setAttribute('aria-disabled', 'true');
|
|
}
|
|
|
|
const MAP_CENTER_COORDS = Object.freeze({ lat: config.mapCenter.lat, lon: config.mapCenter.lon });
|
|
const hasLeaflet = typeof window !== 'undefined' && typeof window.L === 'object' && window.L && typeof window.L.map === 'function';
|
|
const mapContainer = document.getElementById('map');
|
|
const mapPanel = document.getElementById('mapPanel');
|
|
const mapFullscreenToggle = document.getElementById('mapFullscreenToggle');
|
|
const fullscreenContainer = mapPanel || mapContainer;
|
|
const isFederationView = bodyClassList ? bodyClassList.contains('view-federation') : false;
|
|
const legendDefaultCollapsed = mapPanel ? mapPanel.dataset.legendCollapsed === 'true' : false;
|
|
let mapStatusEl = null;
|
|
let map = null;
|
|
let mapCenterLatLng = null;
|
|
let tiles = null;
|
|
let offlineTiles = null;
|
|
let usingOfflineTiles = false;
|
|
const MAX_DISTANCE_KM = Number.isFinite(config.maxDistanceKm) && config.maxDistanceKm > 0
|
|
? config.maxDistanceKm
|
|
: null;
|
|
const LIMIT_DISTANCE = Number.isFinite(MAX_DISTANCE_KM);
|
|
const autoFitBoundsConfig = resolveAutoFitBoundsConfig({
|
|
hasDistanceLimit: LIMIT_DISTANCE,
|
|
maxDistanceKm: MAX_DISTANCE_KM
|
|
});
|
|
const INITIAL_VIEW_PADDING_PX = 12;
|
|
const AUTO_FIT_PADDING_PX = 12;
|
|
const MAX_INITIAL_ZOOM = 13;
|
|
let neighborLinesLayer = null;
|
|
let traceLinesLayer = null;
|
|
let neighborLinesVisible = true;
|
|
let traceLinesVisible = true;
|
|
let neighborLinesToggleButton = null;
|
|
let traceLinesToggleButton = null;
|
|
let markersLayer = null;
|
|
let tileDomObserver = null;
|
|
const fullscreenChangeEvents = [
|
|
'fullscreenchange',
|
|
'webkitfullscreenchange',
|
|
'mozfullscreenchange',
|
|
'MSFullscreenChange',
|
|
'msfullscreenchange'
|
|
];
|
|
|
|
const autoFitController = createMapAutoFitController({
|
|
toggleEl: fitBoundsEl,
|
|
windowObject: typeof window !== 'undefined' ? window : undefined,
|
|
defaultPaddingPx: AUTO_FIT_PADDING_PX
|
|
});
|
|
|
|
const focusMapOnCoordinates = createMapFocusHandler({
|
|
getMap: () => map,
|
|
autoFitController,
|
|
leaflet: hasLeaflet ? window.L : null,
|
|
defaultZoom: DEFAULT_NODE_FOCUS_ZOOM,
|
|
setMapCenter: value => {
|
|
mapCenterLatLng = value;
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Fit the Leaflet map to the provided geographic bounds.
|
|
*
|
|
* @param {[[number, number], [number, number]]|null} bounds Lat/lon bounds tuple.
|
|
* @param {{ animate?: boolean, paddingPx?: number, maxZoom?: number }} [options] Fit options.
|
|
* @returns {void}
|
|
*/
|
|
function fitMapToBounds(bounds, options = {}) {
|
|
if (!map || !bounds) return;
|
|
const padding = Number.isFinite(options.paddingPx) && options.paddingPx >= 0 ? options.paddingPx : AUTO_FIT_PADDING_PX;
|
|
const fitOptions = {
|
|
animate: Boolean(options.animate),
|
|
padding: [padding, padding]
|
|
};
|
|
if (Number.isFinite(options.maxZoom) && options.maxZoom > 0) {
|
|
fitOptions.maxZoom = options.maxZoom;
|
|
}
|
|
autoFitController.recordFit(bounds, { paddingPx: padding, maxZoom: fitOptions.maxZoom });
|
|
autoFitController.runAutoFitOperation(() => {
|
|
map.fitBounds(bounds, fitOptions);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Attempt to reapply the last recorded bounds when auto-fit is enabled.
|
|
*
|
|
* @param {{ animate?: boolean }} [options] Animation preferences.
|
|
* @returns {void}
|
|
*/
|
|
function applyLastRecordedBounds(options = {}) {
|
|
if (!autoFitController.isAutoFitEnabled()) return;
|
|
const snapshot = autoFitController.getLastFit();
|
|
if (!snapshot) return;
|
|
const { bounds, options: fitOptions } = snapshot;
|
|
fitMapToBounds(bounds, {
|
|
animate: Boolean(options.animate),
|
|
paddingPx: fitOptions.paddingPx,
|
|
maxZoom: fitOptions.maxZoom
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Determine whether the browser supports fullscreen requests on the map container.
|
|
*
|
|
* @returns {boolean} True when the Fullscreen API is available for the map element.
|
|
*/
|
|
function supportsMapFullscreen() {
|
|
if (!fullscreenContainer) return false;
|
|
return (
|
|
typeof fullscreenContainer.requestFullscreen === 'function' ||
|
|
typeof fullscreenContainer.webkitRequestFullscreen === 'function' ||
|
|
typeof fullscreenContainer.msRequestFullscreen === 'function'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Resolve the element currently being displayed in fullscreen mode.
|
|
*
|
|
* @returns {Element|null} Active fullscreen element if any.
|
|
*/
|
|
function getActiveFullscreenElement() {
|
|
if (typeof document === 'undefined') return null;
|
|
return (
|
|
document.fullscreenElement ||
|
|
document.webkitFullscreenElement ||
|
|
document.msFullscreenElement ||
|
|
null
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Determine whether the map container is currently in fullscreen mode.
|
|
*
|
|
* @returns {boolean} True when the map container owns fullscreen state.
|
|
*/
|
|
function isMapInFullscreen() {
|
|
if (!fullscreenContainer) return false;
|
|
return getActiveFullscreenElement() === fullscreenContainer;
|
|
}
|
|
|
|
/**
|
|
* Update the fullscreen toggle button label and pressed state.
|
|
*
|
|
* @returns {void}
|
|
*/
|
|
function updateFullscreenToggleState() {
|
|
if (!mapFullscreenToggle) return;
|
|
const active = isMapInFullscreen();
|
|
const label = active ? 'Exit full screen map view' : 'Enter full screen map view';
|
|
mapFullscreenToggle.setAttribute('aria-pressed', active ? 'true' : 'false');
|
|
mapFullscreenToggle.setAttribute('aria-label', label);
|
|
mapFullscreenToggle.dataset.fullscreen = active ? 'true' : 'false';
|
|
}
|
|
|
|
/**
|
|
* Request that the browser place the map container into fullscreen mode.
|
|
*
|
|
* @returns {void}
|
|
*/
|
|
function enterMapFullscreen() {
|
|
if (!fullscreenContainer) return;
|
|
try {
|
|
if (typeof fullscreenContainer.requestFullscreen === 'function') {
|
|
const result = fullscreenContainer.requestFullscreen();
|
|
if (result && typeof result.catch === 'function') {
|
|
result.catch(() => {});
|
|
}
|
|
return;
|
|
}
|
|
if (typeof fullscreenContainer.webkitRequestFullscreen === 'function') {
|
|
fullscreenContainer.webkitRequestFullscreen();
|
|
return;
|
|
}
|
|
if (typeof fullscreenContainer.msRequestFullscreen === 'function') {
|
|
fullscreenContainer.msRequestFullscreen();
|
|
}
|
|
} catch (error) {
|
|
// Ignore errors triggered by the browser blocking fullscreen requests.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Exit fullscreen mode if the map container currently owns it.
|
|
*
|
|
* @returns {void}
|
|
*/
|
|
function exitMapFullscreen() {
|
|
if (typeof document === 'undefined') return;
|
|
try {
|
|
if (typeof document.exitFullscreen === 'function') {
|
|
const result = document.exitFullscreen();
|
|
if (result && typeof result.catch === 'function') {
|
|
result.catch(() => {});
|
|
}
|
|
return;
|
|
}
|
|
if (typeof document.webkitExitFullscreen === 'function') {
|
|
document.webkitExitFullscreen();
|
|
return;
|
|
}
|
|
if (typeof document.msExitFullscreen === 'function') {
|
|
document.msExitFullscreen();
|
|
}
|
|
} catch (error) {
|
|
// Ignore errors triggered by the browser blocking fullscreen exit.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Toggle fullscreen mode depending on the current state.
|
|
*
|
|
* @returns {void}
|
|
*/
|
|
function toggleMapFullscreen() {
|
|
if (!supportsMapFullscreen()) return;
|
|
if (isMapInFullscreen()) {
|
|
exitMapFullscreen();
|
|
} else {
|
|
enterMapFullscreen();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Schedule a Leaflet resize to ensure the map fills the container.
|
|
*
|
|
* @returns {void}
|
|
*/
|
|
function refreshMapSize() {
|
|
if (!map || typeof map.invalidateSize !== 'function') return;
|
|
if (typeof requestAnimationFrame === 'function') {
|
|
requestAnimationFrame(() => {
|
|
map.invalidateSize(true);
|
|
});
|
|
} else {
|
|
setTimeout(() => {
|
|
map.invalidateSize(true);
|
|
}, 160);
|
|
}
|
|
}
|
|
|
|
autoFitController.attachResizeListener(snapshot => {
|
|
refreshMapSize();
|
|
if (!snapshot) return;
|
|
if (!autoFitController.isAutoFitEnabled()) return;
|
|
fitMapToBounds(snapshot.bounds, {
|
|
animate: false,
|
|
paddingPx: snapshot.options.paddingPx,
|
|
maxZoom: snapshot.options.maxZoom
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Append the informational modal overlay to the fullscreen container when active.
|
|
*
|
|
* @returns {void}
|
|
*/
|
|
function attachInfoOverlayToFullscreenHost() {
|
|
if (!infoOverlay || !fullscreenContainer) return;
|
|
if (infoOverlay.parentNode !== fullscreenContainer) {
|
|
fullscreenContainer.appendChild(infoOverlay);
|
|
}
|
|
if (infoOverlay.classList) {
|
|
infoOverlay.classList.add('info-overlay--fullscreen');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Restore the informational overlay to its original DOM position.
|
|
*
|
|
* @returns {void}
|
|
*/
|
|
function restoreInfoOverlayToHome() {
|
|
if (!infoOverlay || !infoOverlayHome || !infoOverlayHome.parent) return;
|
|
if (infoOverlay.parentNode === infoOverlayHome.parent) {
|
|
if (infoOverlay.classList) {
|
|
infoOverlay.classList.remove('info-overlay--fullscreen');
|
|
}
|
|
return;
|
|
}
|
|
if (
|
|
infoOverlayHome.nextSibling &&
|
|
infoOverlayHome.nextSibling.parentNode === infoOverlayHome.parent &&
|
|
typeof infoOverlayHome.parent.insertBefore === 'function'
|
|
) {
|
|
infoOverlayHome.parent.insertBefore(infoOverlay, infoOverlayHome.nextSibling);
|
|
} else if (typeof infoOverlayHome.parent.appendChild === 'function') {
|
|
infoOverlayHome.parent.appendChild(infoOverlay);
|
|
}
|
|
if (infoOverlay.classList) {
|
|
infoOverlay.classList.remove('info-overlay--fullscreen');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensure the informational overlay participates in the active fullscreen subtree.
|
|
*
|
|
* @returns {void}
|
|
*/
|
|
function syncInfoOverlayHost() {
|
|
if (!infoOverlay) return;
|
|
if (isMapInFullscreen()) {
|
|
attachInfoOverlayToFullscreenHost();
|
|
} else {
|
|
restoreInfoOverlayToHome();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Respond to fullscreen change events originating from the browser.
|
|
*
|
|
* @returns {void}
|
|
*/
|
|
function handleFullscreenChange() {
|
|
const active = isMapInFullscreen();
|
|
if (fullscreenContainer && fullscreenContainer.classList) {
|
|
fullscreenContainer.classList.toggle('is-fullscreen', active);
|
|
}
|
|
if (fullscreenContainer !== mapContainer && mapContainer && mapContainer.classList) {
|
|
mapContainer.classList.toggle('is-fullscreen', active);
|
|
}
|
|
if (mapContainer && mapContainer.style) {
|
|
if (active) {
|
|
mapContainer.style.width = '100vw';
|
|
mapContainer.style.height = '100vh';
|
|
mapContainer.style.maxWidth = '100vw';
|
|
mapContainer.style.maxHeight = '100vh';
|
|
mapContainer.style.minWidth = '100vw';
|
|
mapContainer.style.minHeight = '100vh';
|
|
} else {
|
|
mapContainer.style.width = '';
|
|
mapContainer.style.height = '';
|
|
mapContainer.style.maxWidth = '';
|
|
mapContainer.style.maxHeight = '';
|
|
mapContainer.style.minWidth = '';
|
|
mapContainer.style.minHeight = '';
|
|
}
|
|
}
|
|
syncInfoOverlayHost();
|
|
updateFullscreenToggleState();
|
|
refreshMapSize();
|
|
}
|
|
|
|
if (mapFullscreenToggle) {
|
|
if (!supportsMapFullscreen() || typeof document === 'undefined') {
|
|
mapFullscreenToggle.hidden = true;
|
|
} else {
|
|
mapFullscreenToggle.hidden = false;
|
|
mapFullscreenToggle.addEventListener('click', event => {
|
|
event.preventDefault();
|
|
toggleMapFullscreen();
|
|
});
|
|
fullscreenChangeEvents.forEach(eventName => {
|
|
document.addEventListener(eventName, handleFullscreenChange, false);
|
|
});
|
|
updateFullscreenToggleState();
|
|
}
|
|
}
|
|
|
|
syncInfoOverlayHost();
|
|
|
|
const activeRoleFilters = new Set();
|
|
const legendRoleButtons = new Map();
|
|
|
|
/**
|
|
* Lazily create the floating map status element used for progress messages.
|
|
*
|
|
* @returns {HTMLElement|null} Status element attached to the map container.
|
|
*/
|
|
function ensureMapStatusElement() {
|
|
if (!mapContainer) return null;
|
|
if (mapStatusEl && mapStatusEl.parentElement === mapContainer) {
|
|
return mapStatusEl;
|
|
}
|
|
mapStatusEl = document.createElement('div');
|
|
mapStatusEl.className = 'map-status-message';
|
|
mapStatusEl.hidden = true;
|
|
mapContainer.appendChild(mapStatusEl);
|
|
return mapStatusEl;
|
|
}
|
|
|
|
/**
|
|
* Display a short-lived status message overlay on the map.
|
|
*
|
|
* @param {string} message Human readable description of the current state.
|
|
* @returns {void}
|
|
*/
|
|
function showMapStatus(message) {
|
|
if (!mapContainer) return;
|
|
const el = ensureMapStatusElement();
|
|
if (!el) return;
|
|
if (message) {
|
|
el.textContent = message;
|
|
el.hidden = false;
|
|
} else {
|
|
el.hidden = true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hide the map status banner without removing the element from the DOM.
|
|
*
|
|
* @returns {void}
|
|
*/
|
|
function hideMapStatus() {
|
|
if (mapStatusEl) {
|
|
mapStatusEl.hidden = true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Replace the map tiles with a textual placeholder message.
|
|
*
|
|
* @param {string} message Explanation rendered below the placeholder heading.
|
|
* @returns {void}
|
|
*/
|
|
function setMapPlaceholder(message) {
|
|
if (!mapContainer) return;
|
|
mapContainer.dataset.mapStatus = 'placeholder';
|
|
mapContainer.innerHTML = '';
|
|
const placeholder = document.createElement('div');
|
|
placeholder.className = 'map-placeholder-message';
|
|
placeholder.innerHTML = `<strong>Map unavailable</strong>${message ? `<br/><span>${message}</span>` : ''}`;
|
|
mapContainer.appendChild(placeholder);
|
|
}
|
|
|
|
|
|
// --- Map setup ---
|
|
const TILE_LAYER_URL = 'https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png';
|
|
const TILE_FILTER_LIGHT = config.tileFilters.light;
|
|
const TILE_FILTER_DARK = config.tileFilters.dark;
|
|
|
|
if (hasLeaflet) {
|
|
mapCenterLatLng = L.latLng(MAP_CENTER_COORDS.lat, MAP_CENTER_COORDS.lon);
|
|
}
|
|
|
|
/**
|
|
* Return the CSS filter applied to Leaflet tiles for the active theme.
|
|
*
|
|
* @returns {string} CSS filter expression.
|
|
*/
|
|
function resolveTileFilter() {
|
|
return document.body.classList.contains('dark') ? TILE_FILTER_DARK : TILE_FILTER_LIGHT;
|
|
}
|
|
|
|
/**
|
|
* Apply the configured filter to an individual Leaflet tile element.
|
|
*
|
|
* @param {HTMLElement} tile Tile image element.
|
|
* @param {string} filterValue CSS filter expression.
|
|
* @returns {void}
|
|
*/
|
|
function applyFilterToTileElement(tile, filterValue) {
|
|
if (!tile || usingOfflineTiles) return;
|
|
if (tile.classList && !tile.classList.contains('map-tiles')) {
|
|
tile.classList.add('map-tiles');
|
|
}
|
|
const value = filterValue || resolveTileFilter();
|
|
if (tile.style) {
|
|
tile.style.filter = value;
|
|
tile.style.webkitFilter = value;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return the Leaflet DOM container that currently owns map tiles.
|
|
*
|
|
* @returns {HTMLElement|null} Tile layer container element.
|
|
*/
|
|
function getActiveTileLayerContainer() {
|
|
if (!map) return null;
|
|
const layer = usingOfflineTiles ? offlineTiles : tiles;
|
|
return layer && typeof layer.getContainer === 'function' ? layer.getContainer() : null;
|
|
}
|
|
|
|
/**
|
|
* Apply a CSS filter to all currently mounted Leaflet tile elements.
|
|
*
|
|
* @param {string} filterValue CSS filter expression.
|
|
* @returns {void}
|
|
*/
|
|
function applyFilterToTileContainers(filterValue) {
|
|
if (!map) return;
|
|
const value = filterValue || resolveTileFilter();
|
|
const container = getActiveTileLayerContainer();
|
|
if (container && container.style) {
|
|
container.style.filter = value;
|
|
container.style.webkitFilter = value;
|
|
}
|
|
const tilePane = typeof map.getPane === 'function' ? map.getPane('tilePane') : null;
|
|
if (tilePane && tilePane.style) {
|
|
tilePane.style.filter = value;
|
|
tilePane.style.webkitFilter = value;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensure a tile element reflects the active theme filter.
|
|
*
|
|
* @param {HTMLElement} tile Tile element managed by Leaflet.
|
|
* @returns {void}
|
|
*/
|
|
function ensureTileHasCurrentFilter(tile) {
|
|
if (!map || usingOfflineTiles) return;
|
|
const filterValue = resolveTileFilter();
|
|
applyFilterToTileElement(tile, filterValue);
|
|
}
|
|
|
|
/**
|
|
* Synchronise all existing tiles with the current theme filter.
|
|
*
|
|
* @returns {void}
|
|
*/
|
|
function applyFiltersToAllTiles() {
|
|
if (!map) return;
|
|
const filterValue = resolveTileFilter();
|
|
document.body.style.setProperty('--map-tiles-filter', filterValue);
|
|
if (!usingOfflineTiles) {
|
|
const tileEls = mapContainer ? mapContainer.querySelectorAll('.leaflet-tile') : [];
|
|
tileEls.forEach(tile => applyFilterToTileElement(tile, filterValue));
|
|
}
|
|
applyFilterToTileContainers(filterValue);
|
|
}
|
|
|
|
/**
|
|
* Convert a tile X coordinate to longitude degrees.
|
|
*
|
|
* @param {number} x Tile X index.
|
|
* @param {number} z Zoom level.
|
|
* @returns {number} Longitude in degrees.
|
|
*/
|
|
function tileToLon(x, z) {
|
|
return (x / Math.pow(2, z)) * 360 - 180;
|
|
}
|
|
|
|
/**
|
|
* Convert a tile Y coordinate to latitude degrees.
|
|
*
|
|
* @param {number} y Tile Y index.
|
|
* @param {number} z Zoom level.
|
|
* @returns {number} Latitude in degrees.
|
|
*/
|
|
function tileToLat(y, z) {
|
|
const n = Math.PI - (2 * Math.PI * y) / Math.pow(2, z);
|
|
return (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)));
|
|
}
|
|
|
|
/**
|
|
* Create a minimal Leaflet tile layer that renders offline tiles from cache.
|
|
*
|
|
* @returns {L.GridLayer} Configured tile layer instance.
|
|
*/
|
|
function createOfflineTileLayer() {
|
|
if (!hasLeaflet) return null;
|
|
const offlineLayer = L.gridLayer({ className: 'map-tiles map-tiles-offline' });
|
|
/** @type {HTMLElement|null} */
|
|
let cachedOfflineFallbackTile = null;
|
|
|
|
/**
|
|
* Provide a minimal placeholder tile when canvas rendering is not available.
|
|
*
|
|
* @param {number} size Pixel width and height of the tile.
|
|
* @returns {HTMLElement} Cloned fallback element ready for Leaflet consumption.
|
|
*/
|
|
function getOfflineFallbackTile(size) {
|
|
if (!cachedOfflineFallbackTile) {
|
|
const placeholder = document.createElement('div');
|
|
placeholder.className = 'offline-tile-fallback';
|
|
placeholder.style.width = `${size}px`;
|
|
placeholder.style.height = `${size}px`;
|
|
placeholder.style.backgroundColor = 'rgba(33, 66, 110, 0.92)';
|
|
placeholder.style.display = 'flex';
|
|
placeholder.style.alignItems = 'center';
|
|
placeholder.style.justifyContent = 'center';
|
|
placeholder.style.color = 'rgba(255, 255, 255, 0.6)';
|
|
placeholder.style.font = 'bold 14px system-ui, sans-serif';
|
|
placeholder.style.textTransform = 'uppercase';
|
|
placeholder.textContent = 'Offline tile';
|
|
cachedOfflineFallbackTile = placeholder;
|
|
}
|
|
return /** @type {HTMLElement} */ (cachedOfflineFallbackTile.cloneNode(true));
|
|
}
|
|
|
|
/**
|
|
* Render a placeholder tile for offline map usage.
|
|
*
|
|
* @param {{x: number, y: number, z: number}} coords Tile coordinates supplied by Leaflet.
|
|
* @returns {HTMLElement} Tile node containing placeholder artwork.
|
|
*/
|
|
offlineLayer.createTile = coords => {
|
|
const size = 256;
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = size;
|
|
canvas.height = size;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) {
|
|
console.warn('Canvas 2D context unavailable for offline tile rendering. Using fallback placeholder.');
|
|
return getOfflineFallbackTile(size);
|
|
}
|
|
try {
|
|
const gradient = ctx.createLinearGradient(0, 0, size, size);
|
|
gradient.addColorStop(0, 'rgba(33, 66, 110, 0.92)');
|
|
gradient.addColorStop(1, 'rgba(64, 98, 144, 0.92)');
|
|
ctx.fillStyle = gradient;
|
|
ctx.fillRect(0, 0, size, size);
|
|
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
|
|
ctx.lineWidth = 1;
|
|
const steps = 4;
|
|
for (let i = 1; i < steps; i++) {
|
|
const pos = (size / steps) * i;
|
|
ctx.beginPath();
|
|
ctx.moveTo(pos, 0);
|
|
ctx.lineTo(pos, size);
|
|
ctx.stroke();
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, pos);
|
|
ctx.lineTo(size, pos);
|
|
ctx.stroke();
|
|
}
|
|
|
|
const west = tileToLon(coords.x, coords.z);
|
|
const east = tileToLon(coords.x + 1, coords.z);
|
|
const north = tileToLat(coords.y, coords.z);
|
|
const south = tileToLat(coords.y + 1, coords.z);
|
|
|
|
ctx.fillStyle = 'rgba(255,255,255,0.7)';
|
|
ctx.font = '12px system-ui, sans-serif';
|
|
ctx.textBaseline = 'top';
|
|
ctx.fillText(`${west.toFixed(1)}°`, 8, 8);
|
|
ctx.textBaseline = 'bottom';
|
|
ctx.fillText(`${east.toFixed(1)}°`, 8, size - 8);
|
|
ctx.textAlign = 'right';
|
|
ctx.textBaseline = 'top';
|
|
ctx.fillText(`${north.toFixed(1)}°`, size - 8, 8);
|
|
ctx.textBaseline = 'bottom';
|
|
ctx.fillText(`${south.toFixed(1)}°`, size - 8, size - 8);
|
|
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillStyle = 'rgba(255,255,255,0.35)';
|
|
ctx.font = 'bold 22px system-ui, sans-serif';
|
|
ctx.fillText('PotatoMesh offline basemap', size / 2, size / 2);
|
|
|
|
return canvas;
|
|
} catch (error) {
|
|
console.error('Failed to render offline tile. Falling back to placeholder element.', error);
|
|
return getOfflineFallbackTile(size);
|
|
}
|
|
};
|
|
return offlineLayer;
|
|
}
|
|
|
|
/**
|
|
* Disconnect and clear the MutationObserver tracking tile additions.
|
|
*
|
|
* @returns {void}
|
|
*/
|
|
function disconnectTileObserver() {
|
|
if (tileDomObserver) {
|
|
tileDomObserver.disconnect();
|
|
tileDomObserver = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Observe a Leaflet tile container to reapply filters as new tiles load.
|
|
*
|
|
* @param {L.GridLayer} layer Leaflet layer whose container should be watched.
|
|
* @returns {void}
|
|
*/
|
|
function observeTileContainer(layer) {
|
|
if (!map || typeof MutationObserver !== 'function') return;
|
|
const targetLayer = layer || (usingOfflineTiles ? offlineTiles : tiles);
|
|
const container = targetLayer && typeof targetLayer.getContainer === 'function' ? targetLayer.getContainer() : null;
|
|
const tilePane = typeof map.getPane === 'function' ? map.getPane('tilePane') : null;
|
|
const targets = [];
|
|
if (container) targets.push(container);
|
|
if (tilePane && !targets.includes(tilePane)) targets.push(tilePane);
|
|
if (!targets.length) return;
|
|
disconnectTileObserver();
|
|
tileDomObserver = new MutationObserver(mutations => {
|
|
const filterValue = resolveTileFilter();
|
|
mutations.forEach(mutation => {
|
|
mutation.addedNodes.forEach(node => {
|
|
if (!node || node.nodeType !== 1) return;
|
|
if (!usingOfflineTiles && node.classList && node.classList.contains('leaflet-tile')) {
|
|
applyFilterToTileElement(node, filterValue);
|
|
}
|
|
if (typeof node.querySelectorAll === 'function') {
|
|
const nestedTiles = node.querySelectorAll('.leaflet-tile');
|
|
nestedTiles.forEach(tile => applyFilterToTileElement(tile, filterValue));
|
|
}
|
|
});
|
|
});
|
|
applyFilterToTileContainers(filterValue);
|
|
});
|
|
targets.forEach(target => tileDomObserver.observe(target, { childList: true, subtree: true }));
|
|
}
|
|
|
|
/**
|
|
* Enable the offline tile fallback and notify the user.
|
|
*
|
|
* @param {string} message Status text explaining the fallback.
|
|
* @returns {void}
|
|
*/
|
|
function activateOfflineTiles(message) {
|
|
if (!hasLeaflet || !map) {
|
|
if (mapContainer) {
|
|
setMapPlaceholder(
|
|
message || 'Offline basemap unavailable: Leaflet map is not initialized.'
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
if (usingOfflineTiles) {
|
|
if (message) showMapStatus(message);
|
|
return;
|
|
}
|
|
if (!offlineTiles) {
|
|
try {
|
|
offlineTiles = createOfflineTileLayer();
|
|
} catch (error) {
|
|
console.error('Failed to create offline tile layer', error);
|
|
if (mapContainer) {
|
|
const prefix = message ? `${message} ` : '';
|
|
const detail = error && error.message ? ` (${error.message})` : '';
|
|
const errorMessage = `${prefix}Offline fallback could not be initialized.${detail}`;
|
|
setMapPlaceholder(errorMessage);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
if (!offlineTiles) {
|
|
if (mapContainer) {
|
|
const prefix = message ? `${message} ` : '';
|
|
setMapPlaceholder(`${prefix}Offline fallback could not be initialized.`);
|
|
}
|
|
return;
|
|
}
|
|
if (tiles && map.hasLayer(tiles)) {
|
|
map.removeLayer(tiles);
|
|
}
|
|
usingOfflineTiles = true;
|
|
offlineTiles.addTo(map);
|
|
observeTileContainer(offlineTiles);
|
|
if (message) {
|
|
showMapStatus(message);
|
|
}
|
|
applyFiltersToAllTiles();
|
|
}
|
|
|
|
const mapAlreadyInitialized = mapContainer && mapContainer._leaflet_id;
|
|
|
|
if (hasLeaflet && mapContainer && !isFederationView && !mapAlreadyInitialized) {
|
|
map = L.map(mapContainer, { worldCopyJump: true, attributionControl: false });
|
|
showMapStatus('Loading map tiles…');
|
|
tiles = L.tileLayer(TILE_LAYER_URL, {
|
|
maxZoom: 19,
|
|
className: 'map-tiles',
|
|
crossOrigin: 'anonymous'
|
|
});
|
|
|
|
tiles.on('tileloadstart', event => {
|
|
if (!event || !event.tile) return;
|
|
ensureTileHasCurrentFilter(event.tile);
|
|
applyFilterToTileContainers();
|
|
});
|
|
|
|
tiles.on('tileload', event => {
|
|
if (!event || !event.tile) return;
|
|
ensureTileHasCurrentFilter(event.tile);
|
|
applyFilterToTileContainers();
|
|
});
|
|
|
|
tiles.on('load', () => {
|
|
usingOfflineTiles = false;
|
|
hideMapStatus();
|
|
applyFiltersToAllTiles();
|
|
observeTileContainer(tiles);
|
|
});
|
|
|
|
tiles.on('tileerror', () => {
|
|
activateOfflineTiles('Map tiles unavailable. Showing offline placeholder basemap.');
|
|
});
|
|
|
|
tiles.addTo(map);
|
|
observeTileContainer(tiles);
|
|
|
|
const initialBounds = computeBoundingBox(
|
|
MAP_CENTER_COORDS,
|
|
LIMIT_DISTANCE ? MAX_DISTANCE_KM : null,
|
|
{ minimumRangeKm: 1 }
|
|
);
|
|
if (mapZoomOverride !== null) {
|
|
map.setView([MAP_CENTER_COORDS.lat, MAP_CENTER_COORDS.lon], mapZoomOverride);
|
|
} else if (initialBounds) {
|
|
fitMapToBounds(initialBounds, { animate: false, paddingPx: INITIAL_VIEW_PADDING_PX, maxZoom: MAX_INITIAL_ZOOM });
|
|
} else if (mapCenterLatLng) {
|
|
map.setView(mapCenterLatLng, 10);
|
|
} else {
|
|
map.setView([MAP_CENTER_COORDS.lat, MAP_CENTER_COORDS.lon], 10);
|
|
}
|
|
|
|
if (typeof map.whenReady === 'function') {
|
|
map.whenReady(() => {
|
|
applyFiltersToAllTiles();
|
|
refreshMapSize();
|
|
applyLastRecordedBounds({ animate: false });
|
|
});
|
|
} else {
|
|
applyFiltersToAllTiles();
|
|
applyLastRecordedBounds({ animate: false });
|
|
}
|
|
|
|
map.on('moveend', applyFiltersToAllTiles);
|
|
map.on('zoomend', applyFiltersToAllTiles);
|
|
map.on('movestart', () => {
|
|
autoFitController.handleUserInteraction();
|
|
});
|
|
map.on('zoomstart', () => {
|
|
autoFitController.handleUserInteraction();
|
|
});
|
|
|
|
neighborLinesLayer = L.layerGroup().addTo(map);
|
|
traceLinesLayer = L.layerGroup().addTo(map);
|
|
markersLayer = L.layerGroup().addTo(map);
|
|
|
|
if (typeof navigator !== 'undefined' && navigator && navigator.onLine === false) {
|
|
activateOfflineTiles('Offline mode detected. Using placeholder basemap.');
|
|
}
|
|
} else if (mapContainer && !isFederationView) {
|
|
setMapPlaceholder('Leaflet assets are unavailable. Data will continue to refresh without a live map.');
|
|
}
|
|
|
|
if (typeof window !== 'undefined') {
|
|
/**
|
|
* Helper exposed for the theme module to refresh Leaflet tile filters.
|
|
*
|
|
* @type {function(): void}
|
|
*/
|
|
window.applyFiltersToAllTiles = applyFiltersToAllTiles;
|
|
}
|
|
|
|
let legendContainer = null;
|
|
let legendToggleControl = null;
|
|
let legendToggleButton = null;
|
|
let legendVisible = true;
|
|
|
|
/**
|
|
* Update the pressed state of the legend visibility toggle button.
|
|
*
|
|
* @returns {void}
|
|
*/
|
|
function updateLegendToggleState() {
|
|
if (!legendToggleButton) return;
|
|
const hasFilters = activeRoleFilters.size > 0;
|
|
legendToggleButton.setAttribute('aria-pressed', legendVisible ? 'true' : 'false');
|
|
const baseLabel = legendVisible ? 'Hide map legend' : 'Show map legend';
|
|
const baseText = legendVisible ? 'Hide legend' : 'Show legend';
|
|
const labelSuffix = hasFilters ? ' (role filters active)' : '';
|
|
const textSuffix = ' (filters)';
|
|
legendToggleButton.setAttribute('aria-label', baseLabel + labelSuffix);
|
|
legendToggleButton.textContent = baseText + textSuffix;
|
|
if (hasFilters) {
|
|
legendToggleButton.setAttribute('data-has-active-filters', 'true');
|
|
} else {
|
|
legendToggleButton.removeAttribute('data-has-active-filters');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show or hide the map legend component.
|
|
*
|
|
* @param {boolean} visible Whether the legend should be displayed.
|
|
* @returns {void}
|
|
*/
|
|
function setLegendVisibility(visible) {
|
|
legendVisible = visible;
|
|
if (legendContainer) {
|
|
legendContainer.classList.toggle('legend-hidden', !visible);
|
|
legendContainer.setAttribute('aria-hidden', visible ? 'false' : 'true');
|
|
}
|
|
updateLegendToggleState();
|
|
}
|
|
|
|
/**
|
|
* Synchronise the neighbour line toggle button with the active state.
|
|
*
|
|
* @returns {void}
|
|
*/
|
|
function updateNeighborLinesToggleState() {
|
|
if (!neighborLinesToggleButton) return;
|
|
const label = neighborLinesVisible ? 'Hide neighbor lines' : 'Show neighbor lines';
|
|
neighborLinesToggleButton.textContent = label;
|
|
neighborLinesToggleButton.setAttribute('aria-pressed', neighborLinesVisible ? 'true' : 'false');
|
|
neighborLinesToggleButton.setAttribute('aria-label', label);
|
|
}
|
|
|
|
/**
|
|
* Toggle the Leaflet layer that renders neighbour connection lines.
|
|
*
|
|
* @param {boolean} visible Whether to show the layer.
|
|
* @returns {void}
|
|
*/
|
|
function setNeighborLinesVisibility(visible) {
|
|
neighborLinesVisible = Boolean(visible);
|
|
if (neighborLinesLayer && map) {
|
|
const hasLayer = map.hasLayer(neighborLinesLayer);
|
|
if (neighborLinesVisible && !hasLayer) {
|
|
neighborLinesLayer.addTo(map);
|
|
} else if (!neighborLinesVisible && hasLayer) {
|
|
map.removeLayer(neighborLinesLayer);
|
|
}
|
|
}
|
|
updateNeighborLinesToggleState();
|
|
}
|
|
|
|
/**
|
|
* Synchronise the traceroute line toggle button with the active state.
|
|
*
|
|
* @returns {void}
|
|
*/
|
|
function updateTraceLinesToggleState() {
|
|
if (!traceLinesToggleButton) return;
|
|
const label = traceLinesVisible ? 'Hide trace lines' : 'Show trace lines';
|
|
traceLinesToggleButton.textContent = label;
|
|
traceLinesToggleButton.setAttribute('aria-pressed', traceLinesVisible ? 'true' : 'false');
|
|
traceLinesToggleButton.setAttribute('aria-label', label);
|
|
}
|
|
|
|
/**
|
|
* Toggle the Leaflet layer that renders traceroute connections.
|
|
*
|
|
* @param {boolean} visible Whether to show traceroute paths.
|
|
* @returns {void}
|
|
*/
|
|
function setTraceLinesVisibility(visible) {
|
|
traceLinesVisible = Boolean(visible);
|
|
if (traceLinesLayer && map) {
|
|
const hasLayer = map.hasLayer(traceLinesLayer);
|
|
if (traceLinesVisible && !hasLayer) {
|
|
traceLinesLayer.addTo(map);
|
|
} else if (!traceLinesVisible && hasLayer) {
|
|
map.removeLayer(traceLinesLayer);
|
|
}
|
|
}
|
|
updateTraceLinesToggleState();
|
|
}
|
|
|
|
/**
|
|
* Refresh the legend buttons to reflect the active role filters.
|
|
*
|
|
* @returns {void}
|
|
*/
|
|
function updateLegendRoleFiltersUI() {
|
|
const hasFilters = activeRoleFilters.size > 0;
|
|
legendRoleButtons.forEach((button, role) => {
|
|
if (!button) return;
|
|
const isActive = activeRoleFilters.has(role);
|
|
button.setAttribute('aria-pressed', isActive ? 'true' : 'false');
|
|
});
|
|
if (legendContainer) {
|
|
if (hasFilters) {
|
|
legendContainer.setAttribute('data-has-active-filters', 'true');
|
|
} else {
|
|
legendContainer.removeAttribute('data-has-active-filters');
|
|
}
|
|
}
|
|
updateLegendToggleState();
|
|
}
|
|
|
|
/**
|
|
* Toggle the visibility filter for a given role.
|
|
*
|
|
* @param {string} role Role identifier.
|
|
* @returns {void}
|
|
*/
|
|
function toggleRoleFilter(role) {
|
|
if (!role) return;
|
|
if (activeRoleFilters.has(role)) {
|
|
activeRoleFilters.delete(role);
|
|
} else {
|
|
activeRoleFilters.add(role);
|
|
}
|
|
updateLegendRoleFiltersUI();
|
|
applyFilter();
|
|
}
|
|
|
|
if (map && hasLeaflet) {
|
|
const legend = L.control({ position: 'bottomright' });
|
|
/**
|
|
* Leaflet control factory that renders the legend UI.
|
|
*
|
|
* @returns {HTMLElement} Legend DOM element.
|
|
*/
|
|
legend.onAdd = function () {
|
|
const div = L.DomUtil.create('div', 'legend');
|
|
div.id = 'mapLegend';
|
|
div.setAttribute('role', 'region');
|
|
div.setAttribute('aria-label', 'Map legend');
|
|
legendContainer = div;
|
|
|
|
const header = L.DomUtil.create('div', 'legend-header', div);
|
|
const title = L.DomUtil.create('span', 'legend-title', header);
|
|
title.textContent = 'Legend';
|
|
|
|
const itemsContainer = L.DomUtil.create('div', 'legend-items', div);
|
|
legendRoleButtons.clear();
|
|
for (const [role, color] of Object.entries(roleColors)) {
|
|
if (!CHAT_ENABLED && role === 'CLIENT_HIDDEN') continue;
|
|
const item = L.DomUtil.create('button', 'legend-item', itemsContainer);
|
|
item.type = 'button';
|
|
item.setAttribute('aria-pressed', 'false');
|
|
item.dataset.role = role;
|
|
const swatch = L.DomUtil.create('span', 'legend-swatch', item);
|
|
swatch.style.background = color;
|
|
swatch.setAttribute('aria-hidden', 'true');
|
|
const label = L.DomUtil.create('span', 'legend-label', item);
|
|
label.textContent = role;
|
|
item.addEventListener('click', event => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const exclusive = event.metaKey || event.ctrlKey;
|
|
if (exclusive) {
|
|
activeRoleFilters.clear();
|
|
activeRoleFilters.add(role);
|
|
updateLegendRoleFiltersUI();
|
|
applyFilter();
|
|
} else {
|
|
toggleRoleFilter(role);
|
|
}
|
|
});
|
|
legendRoleButtons.set(role, item);
|
|
}
|
|
updateLegendRoleFiltersUI();
|
|
|
|
const toggle = L.DomUtil.create('div', 'legend-toggle', div);
|
|
neighborLinesToggleButton = L.DomUtil.create('button', 'legend-item legend-toggle-neighbors', toggle);
|
|
neighborLinesToggleButton.type = 'button';
|
|
neighborLinesToggleButton.addEventListener('click', event => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
setNeighborLinesVisibility(!neighborLinesVisible);
|
|
});
|
|
updateNeighborLinesToggleState();
|
|
|
|
traceLinesToggleButton = L.DomUtil.create('button', 'legend-item legend-toggle-traces', toggle);
|
|
traceLinesToggleButton.type = 'button';
|
|
traceLinesToggleButton.addEventListener('click', event => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
setTraceLinesVisibility(!traceLinesVisible);
|
|
});
|
|
updateTraceLinesToggleState();
|
|
|
|
const resetButton = L.DomUtil.create('button', 'legend-item legend-reset', toggle);
|
|
resetButton.type = 'button';
|
|
resetButton.textContent = 'Clear filters';
|
|
resetButton.addEventListener('click', event => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
activeRoleFilters.clear();
|
|
updateLegendRoleFiltersUI();
|
|
applyFilter();
|
|
});
|
|
|
|
L.DomEvent.disableClickPropagation(div);
|
|
L.DomEvent.disableScrollPropagation(div);
|
|
|
|
return div;
|
|
};
|
|
legend.addTo(map);
|
|
legendContainer = legend.getContainer();
|
|
|
|
legendToggleControl = L.control({ position: 'bottomright' });
|
|
/**
|
|
* Leaflet control factory for the legend visibility toggle.
|
|
*
|
|
* @returns {HTMLElement} Toggle button element.
|
|
*/
|
|
legendToggleControl.onAdd = function () {
|
|
const container = L.DomUtil.create('div', 'leaflet-control legend-toggle');
|
|
const button = L.DomUtil.create('button', 'legend-toggle-button', container);
|
|
button.type = 'button';
|
|
button.textContent = 'Hide legend (filters)';
|
|
button.setAttribute('aria-pressed', 'true');
|
|
button.setAttribute('aria-label', 'Hide map legend');
|
|
button.setAttribute('aria-controls', 'mapLegend');
|
|
button.addEventListener('click', event => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
setLegendVisibility(!legendVisible);
|
|
});
|
|
legendToggleButton = button;
|
|
updateLegendToggleState();
|
|
L.DomEvent.disableClickPropagation(container);
|
|
L.DomEvent.disableScrollPropagation(container);
|
|
return container;
|
|
};
|
|
legendToggleControl.addTo(map);
|
|
|
|
const legendMediaQuery = window.matchMedia('(max-width: 1024px)');
|
|
const initialLegendVisible = resolveLegendVisibility({
|
|
defaultCollapsed: legendDefaultCollapsed,
|
|
mediaQueryMatches: legendMediaQuery.matches,
|
|
viewMode: isDashboardView ? 'dashboard' : (isMapView ? 'map' : undefined)
|
|
});
|
|
setLegendVisibility(initialLegendVisible);
|
|
legendMediaQuery.addEventListener('change', event => {
|
|
if (legendDefaultCollapsed || isDashboardView || isMapView) return;
|
|
setLegendVisibility(!event.matches);
|
|
});
|
|
} else if (mapContainer && !hasLeaflet) {
|
|
setLegendVisibility(false);
|
|
}
|
|
|
|
themeToggle.addEventListener('click', () => {
|
|
const dark = document.body.classList.toggle('dark');
|
|
const themeValue = dark ? 'dark' : 'light';
|
|
document.body.setAttribute('data-theme', themeValue);
|
|
if (document.documentElement) {
|
|
document.documentElement.setAttribute('data-theme', themeValue);
|
|
}
|
|
themeToggle.textContent = dark ? '☀️' : '🌙';
|
|
if (window.__themeCookie) {
|
|
if (typeof window.__themeCookie.persistTheme === 'function') {
|
|
window.__themeCookie.persistTheme(themeValue);
|
|
} else if (typeof window.__themeCookie.setCookie === 'function') {
|
|
window.__themeCookie.setCookie('theme', themeValue);
|
|
}
|
|
}
|
|
window.dispatchEvent(new CustomEvent('themechange', { detail: { theme: themeValue } }));
|
|
if (typeof window.applyFiltersToAllTiles === 'function') window.applyFiltersToAllTiles();
|
|
});
|
|
|
|
let lastFocusBeforeInfo = null;
|
|
|
|
/**
|
|
* Display the modal overlay containing site information.
|
|
*
|
|
* @returns {void}
|
|
*/
|
|
function openInfoOverlay() {
|
|
if (!infoOverlay || !infoDialog) return;
|
|
syncInfoOverlayHost();
|
|
lastFocusBeforeInfo = document.activeElement;
|
|
infoOverlay.hidden = false;
|
|
document.body.style.setProperty('overflow', 'hidden');
|
|
infoDialog.focus();
|
|
}
|
|
|
|
/**
|
|
* Hide the site information overlay and restore focus.
|
|
*
|
|
* @returns {void}
|
|
*/
|
|
function closeInfoOverlay() {
|
|
if (!infoOverlay || !infoDialog) return;
|
|
infoOverlay.hidden = true;
|
|
document.body.style.removeProperty('overflow');
|
|
const target = lastFocusBeforeInfo && typeof lastFocusBeforeInfo.focus === 'function' ? lastFocusBeforeInfo : infoBtn;
|
|
if (target && typeof target.focus === 'function') {
|
|
target.focus();
|
|
}
|
|
lastFocusBeforeInfo = null;
|
|
}
|
|
|
|
if (infoBtn && infoOverlay && infoClose) {
|
|
infoBtn.addEventListener('click', openInfoOverlay);
|
|
infoClose.addEventListener('click', closeInfoOverlay);
|
|
infoOverlay.addEventListener('click', event => {
|
|
if (event.target === infoOverlay) {
|
|
closeInfoOverlay();
|
|
}
|
|
});
|
|
document.addEventListener('keydown', event => {
|
|
if (event.key === 'Escape' && !infoOverlay.hidden) {
|
|
closeInfoOverlay();
|
|
}
|
|
});
|
|
}
|
|
|
|
const nodeDetailOverlayManager = createNodeDetailOverlayManager({
|
|
document,
|
|
privateMode: isPrivateMode,
|
|
});
|
|
|
|
document.addEventListener('click', event => {
|
|
const longNameLink = event.target.closest('.node-long-link');
|
|
if (
|
|
longNameLink &&
|
|
nodeDetailOverlayManager &&
|
|
shouldHandleNodeLongLink(longNameLink) &&
|
|
!(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
|
|
) {
|
|
const identifier = getNodeIdentifierFromLink(longNameLink);
|
|
if (identifier) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
overlayStack.closeAll();
|
|
const label = typeof longNameLink.textContent === 'string' ? longNameLink.textContent.trim() : '';
|
|
nodeDetailOverlayManager.open({ nodeId: identifier }, { trigger: longNameLink, label })
|
|
.catch(err => console.error('Failed to open node detail overlay', err));
|
|
return;
|
|
}
|
|
}
|
|
|
|
const shortTarget = event.target.closest('.short-name');
|
|
if (
|
|
shortTarget &&
|
|
shortTarget.dataset &&
|
|
(shortTarget.dataset.nodeInfo || shortTarget.dataset.nodeId || shortTarget.dataset.nodeNum)
|
|
) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
let fallbackInfo = null;
|
|
if (shortTarget.dataset.nodeInfo) {
|
|
try {
|
|
fallbackInfo = JSON.parse(shortTarget.dataset.nodeInfo);
|
|
} catch (err) {
|
|
console.warn('Failed to parse node info payload', err);
|
|
}
|
|
}
|
|
if (!fallbackInfo || typeof fallbackInfo !== 'object') {
|
|
fallbackInfo = {};
|
|
}
|
|
|
|
const datasetNodeId = typeof shortTarget.dataset.nodeId === 'string'
|
|
? shortTarget.dataset.nodeId.trim()
|
|
: '';
|
|
if (datasetNodeId && !fallbackInfo.nodeId && !fallbackInfo.node_id) {
|
|
fallbackInfo.nodeId = datasetNodeId;
|
|
}
|
|
|
|
if (fallbackInfo.nodeNum == null && fallbackInfo.node_num == null && shortTarget.dataset.nodeNum != null) {
|
|
const parsedDatasetNum = Number(shortTarget.dataset.nodeNum);
|
|
if (Number.isFinite(parsedDatasetNum)) {
|
|
fallbackInfo.nodeNum = parsedDatasetNum;
|
|
}
|
|
}
|
|
|
|
if (!fallbackInfo.shortName && shortTarget.textContent) {
|
|
fallbackInfo.shortName = shortTarget.textContent.replace(/\u00a0/g, ' ').trim();
|
|
}
|
|
|
|
const fallbackDetails = mergeOverlayDetails(null, fallbackInfo);
|
|
if (!fallbackDetails.shortName && shortTarget.textContent) {
|
|
fallbackDetails.shortName = shortTarget.textContent.replace(/\u00a0/g, ' ').trim();
|
|
fallbackInfo.shortName = fallbackDetails.shortName;
|
|
}
|
|
|
|
if (overlayStack.isOpen(shortTarget)) {
|
|
overlayStack.close(shortTarget);
|
|
return;
|
|
}
|
|
|
|
const nodeId = typeof fallbackDetails.nodeId === 'string' && fallbackDetails.nodeId.trim().length
|
|
? fallbackDetails.nodeId.trim()
|
|
: '';
|
|
const nodeNum = Number.isFinite(fallbackDetails.nodeNum) ? fallbackDetails.nodeNum : null;
|
|
|
|
if (!nodeId && !nodeNum) {
|
|
closeUnrelatedShortOverlays(shortTarget);
|
|
openShortInfoOverlay(shortTarget, fallbackDetails);
|
|
return;
|
|
}
|
|
|
|
const requestId = overlayStack.incrementRequestToken(shortTarget);
|
|
showShortInfoLoading(shortTarget, fallbackDetails);
|
|
|
|
refreshNodeInformation({ nodeId: nodeId || undefined, nodeNum: nodeNum ?? undefined, fallback: fallbackInfo })
|
|
.then(details => {
|
|
if (!overlayStack.isTokenCurrent(shortTarget, requestId)) return;
|
|
const overlayDetails = mergeOverlayDetails(details, fallbackInfo);
|
|
if (!overlayDetails.shortName && shortTarget.textContent) {
|
|
overlayDetails.shortName = shortTarget.textContent.replace(/\u00a0/g, ' ').trim();
|
|
}
|
|
closeUnrelatedShortOverlays(shortTarget);
|
|
openShortInfoOverlay(shortTarget, overlayDetails);
|
|
})
|
|
.catch(err => {
|
|
console.warn('Failed to refresh node information', err);
|
|
if (!overlayStack.isTokenCurrent(shortTarget, requestId)) return;
|
|
const overlayDetails = mergeOverlayDetails(null, fallbackInfo);
|
|
if (!overlayDetails.shortName && shortTarget.textContent) {
|
|
overlayDetails.shortName = shortTarget.textContent.replace(/\u00a0/g, ' ').trim();
|
|
}
|
|
closeUnrelatedShortOverlays(shortTarget);
|
|
openShortInfoOverlay(shortTarget, overlayDetails);
|
|
});
|
|
return;
|
|
}
|
|
if (event.target.closest('.neighbor-connection-line')) {
|
|
return;
|
|
}
|
|
if (overlayStack.containsNode(event.target)) {
|
|
return;
|
|
}
|
|
overlayStack.closeAll();
|
|
});
|
|
|
|
window.addEventListener('resize', () => {
|
|
overlayStack.positionAll();
|
|
});
|
|
window.addEventListener('scroll', () => {
|
|
overlayStack.positionAll();
|
|
});
|
|
|
|
// --- Helpers ---
|
|
/**
|
|
* Escape a string for safe HTML insertion.
|
|
*
|
|
* @param {string} str Raw string.
|
|
* @returns {string} Escaped HTML string.
|
|
*/
|
|
function escapeHtml(str) {
|
|
return String(str)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
/**
|
|
* Render a short name badge with role-based styling.
|
|
*
|
|
* @param {string} short Short node identifier.
|
|
* @param {string} role Node role string.
|
|
* @param {string} longName Full node name.
|
|
* @param {?Object} nodeData Optional node metadata attached to the badge.
|
|
* @returns {string} HTML snippet describing the badge.
|
|
*/
|
|
function renderShortHtml(short, role, longName, nodeData = null) {
|
|
const safeTitle = longName ? escapeHtml(String(longName)) : '';
|
|
const titleAttr = safeTitle ? ` title="${safeTitle}"` : '';
|
|
const roleValue = normalizeRole(role != null && role !== '' ? role : (nodeData && nodeData.role));
|
|
let infoAttr = '';
|
|
if (nodeData && typeof nodeData === 'object') {
|
|
const info = {
|
|
nodeId: nodeData.node_id ?? nodeData.nodeId ?? '',
|
|
nodeNum: nodeData.num ?? nodeData.node_num ?? nodeData.nodeNum ?? null,
|
|
shortName: short != null ? String(short) : (nodeData.short_name ?? ''),
|
|
longName: nodeData.long_name ?? longName ?? '',
|
|
role: roleValue,
|
|
hwModel: nodeData.hw_model ?? nodeData.hwModel ?? '',
|
|
telemetryTime: nodeData.telemetry_time ?? nodeData.telemetryTime ?? null,
|
|
};
|
|
Object.assign(info, collectTelemetryMetrics(nodeData));
|
|
const attrParts = [` data-node-info="${escapeHtml(JSON.stringify(info))}"`];
|
|
const attrNodeIdRaw = info.nodeId != null ? String(info.nodeId).trim() : '';
|
|
if (attrNodeIdRaw) {
|
|
attrParts.push(` data-node-id="${escapeHtml(attrNodeIdRaw)}"`);
|
|
}
|
|
const attrNodeNum = Number(info.nodeNum);
|
|
if (Number.isFinite(attrNodeNum)) {
|
|
attrParts.push(` data-node-num="${escapeHtml(String(attrNodeNum))}"`);
|
|
}
|
|
infoAttr = attrParts.join('');
|
|
}
|
|
if (!short) {
|
|
return `<span class="short-name" style="background:#ccc"${titleAttr}${infoAttr}>? </span>`;
|
|
}
|
|
const padded = escapeHtml(String(short).padStart(4, ' ')).replace(/ /g, ' ');
|
|
const color = getRoleColor(roleValue);
|
|
return `<span class="short-name" style="background:${color}"${titleAttr}${infoAttr}>${padded}</span>`;
|
|
}
|
|
|
|
const potatoMeshNamespace = globalThis.PotatoMesh || (globalThis.PotatoMesh = {});
|
|
potatoMeshNamespace.renderShortHtml = renderShortHtml;
|
|
potatoMeshNamespace.getRoleColor = getRoleColor;
|
|
potatoMeshNamespace.getRoleKey = getRoleKey;
|
|
potatoMeshNamespace.normalizeRole = normalizeRole;
|
|
|
|
/**
|
|
* Escape a CSS selector fragment with a defensive fallback for
|
|
* environments lacking ``CSS.escape`` support.
|
|
*
|
|
* @param {string} value Raw selector fragment.
|
|
* @returns {string} Escaped selector fragment safe for interpolation.
|
|
*/
|
|
function cssEscape(value) {
|
|
if (typeof value !== 'string' || value.length === 0) {
|
|
return '';
|
|
}
|
|
if (typeof window !== 'undefined' && window.CSS && typeof window.CSS.escape === 'function') {
|
|
return window.CSS.escape(value);
|
|
}
|
|
return value.replace(/[^a-zA-Z0-9_-]/g, chr => `\\${chr}`);
|
|
}
|
|
|
|
/**
|
|
* Parse a node identifier or numeric reference into a finite number.
|
|
*
|
|
* @param {*} ref Identifier or numeric reference.
|
|
* @returns {number|null} Parsed number or ``null``.
|
|
*/
|
|
function parseNodeNumericRef(ref) {
|
|
if (ref == null) return null;
|
|
if (typeof ref === 'number') {
|
|
return Number.isFinite(ref) ? ref : null;
|
|
}
|
|
if (typeof ref === 'string') {
|
|
const trimmed = ref.trim();
|
|
if (!trimmed) return null;
|
|
if (trimmed.startsWith('!')) {
|
|
const hex = trimmed.slice(1);
|
|
if (!/^[0-9A-Fa-f]+$/.test(hex)) return null;
|
|
const parsedHex = Number.parseInt(hex, 16);
|
|
return Number.isFinite(parsedHex) ? parsedHex >>> 0 : null;
|
|
}
|
|
if (/^0[xX][0-9A-Fa-f]+$/.test(trimmed)) {
|
|
const parsedHex = Number.parseInt(trimmed, 16);
|
|
return Number.isFinite(parsedHex) ? parsedHex >>> 0 : null;
|
|
}
|
|
const parsed = Number(trimmed);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
const parsed = Number(ref);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
/**
|
|
* Populate the ``nodesById`` index for quick lookups.
|
|
*
|
|
* @param {Array<Object>} nodes Collection of node payloads.
|
|
* @returns {void}
|
|
*/
|
|
function rebuildNodeIndex(nodes) {
|
|
nodesById = new Map();
|
|
nodesByNum = new Map();
|
|
if (!Array.isArray(nodes)) return;
|
|
for (const node of nodes) {
|
|
if (!node || typeof node !== 'object') continue;
|
|
const nodeIdRaw = typeof node.node_id === 'string'
|
|
? node.node_id
|
|
: (typeof node.nodeId === 'string' ? node.nodeId : null);
|
|
if (nodeIdRaw) {
|
|
nodesById.set(nodeIdRaw.trim(), node);
|
|
const numericFromId = parseNodeNumericRef(nodeIdRaw);
|
|
if (numericFromId != null && !nodesByNum.has(numericFromId)) {
|
|
nodesByNum.set(numericFromId, node);
|
|
}
|
|
}
|
|
const nodeNumRaw = node.num ?? node.node_num ?? node.nodeNum;
|
|
const nodeNum = parseNodeNumericRef(nodeNumRaw);
|
|
if (Number.isFinite(nodeNum)) {
|
|
nodesByNum.set(nodeNum, node);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return neighbour entries associated with a node.
|
|
*
|
|
* @param {string} nodeId Canonical node identifier.
|
|
* @returns {Array<Object>} Neighbour records sorted by SNR.
|
|
*/
|
|
function getNeighborNodesFor(nodeId) {
|
|
if (typeof nodeId !== 'string' || nodeId.length === 0) return [];
|
|
if (!Array.isArray(allNeighbors) || allNeighbors.length === 0) return [];
|
|
const neighborsById = new Map();
|
|
for (const entry of allNeighbors) {
|
|
if (!entry || typeof entry !== 'object') continue;
|
|
const sourceId = typeof entry.node_id === 'string' ? entry.node_id : null;
|
|
if (sourceId !== nodeId) continue;
|
|
const neighborId = typeof entry.neighbor_id === 'string' ? entry.neighbor_id : null;
|
|
if (!neighborId) continue;
|
|
const snrValue = toFiniteNumber(entry.snr);
|
|
const rxTime = resolveTimestampSeconds(entry.rx_time, entry.rxTime);
|
|
let record = neighborsById.get(neighborId);
|
|
if (!record) {
|
|
let neighborNode = nodesById.get(neighborId);
|
|
if (!neighborNode) {
|
|
const placeholder = { node_id: neighborId };
|
|
applyNodeNameFallback(placeholder);
|
|
neighborNode = placeholder;
|
|
}
|
|
record = {
|
|
node: neighborNode,
|
|
neighborId,
|
|
snr: snrValue != null ? snrValue : null,
|
|
rxTime: rxTime != null ? rxTime : null
|
|
};
|
|
neighborsById.set(neighborId, record);
|
|
continue;
|
|
}
|
|
if (snrValue != null && (record.snr == null || snrValue > record.snr)) {
|
|
record.snr = snrValue;
|
|
}
|
|
if (rxTime != null && (record.rxTime == null || rxTime > record.rxTime)) {
|
|
record.rxTime = rxTime;
|
|
}
|
|
}
|
|
return Array.from(neighborsById.values());
|
|
}
|
|
|
|
/**
|
|
* Render HTML describing a neighbour relationship and SNR value.
|
|
*
|
|
* @param {Object} neighborEntry Neighbor payload.
|
|
* @returns {string} HTML snippet for map tooltips.
|
|
*/
|
|
function renderNeighborWithSnrHtml(neighborEntry) {
|
|
if (!neighborEntry || !neighborEntry.node) return '';
|
|
const node = neighborEntry.node;
|
|
const shortHtml = renderShortHtml(
|
|
node && (node.short_name ?? node.shortName),
|
|
node && node.role,
|
|
node && (node.long_name ?? node.longName),
|
|
node
|
|
);
|
|
if (!shortHtml) return '';
|
|
const snrText = shortInfoValueOrDash(formatSnrDisplay(neighborEntry.snr));
|
|
return `${shortHtml}<span class="neighbor-snr">(SNR ${escapeHtml(snrText)})</span>`;
|
|
}
|
|
|
|
/**
|
|
* Build HTML markup describing a node for a Leaflet popup.
|
|
*
|
|
* @param {Object} node Map node payload with snake_case keys.
|
|
* @param {number} nowSec Reference timestamp for relative calculations.
|
|
* @returns {string} HTML snippet rendered inside the popup.
|
|
*/
|
|
function buildMapPopupHtml(node, nowSec) {
|
|
const lines = [];
|
|
const longNameLink = renderNodeLongNameLink(node?.long_name, node?.node_id);
|
|
if (longNameLink) {
|
|
lines.push(`<b>${longNameLink}</b>`);
|
|
}
|
|
|
|
const shortHtml = renderShortHtml(node?.short_name, node?.role, node?.long_name, node);
|
|
const nodeIdText = node && node.node_id ? `<span class="mono">${escapeHtml(String(node.node_id))}</span>` : '';
|
|
const shortParts = [];
|
|
if (shortHtml) shortParts.push(shortHtml);
|
|
if (nodeIdText) shortParts.push(nodeIdText);
|
|
if (shortParts.length) {
|
|
lines.push(shortParts.join(' '));
|
|
}
|
|
|
|
const hardwareText = fmtHw(node?.hw_model);
|
|
if (hardwareText) {
|
|
lines.push(`Model: ${escapeHtml(hardwareText)}`);
|
|
}
|
|
|
|
const roleValue = node?.role || 'CLIENT';
|
|
if (roleValue) {
|
|
lines.push(`Role: ${escapeHtml(roleValue)}`);
|
|
}
|
|
|
|
const batteryParts = [];
|
|
const batteryText = fmtAlt(node?.battery_level, '%');
|
|
if (batteryText) batteryParts.push(batteryText);
|
|
const voltageText = fmtAlt(node?.voltage, 'V');
|
|
if (voltageText) batteryParts.push(voltageText);
|
|
if (batteryParts.length) {
|
|
lines.push(`Battery: ${batteryParts.join(', ')}`);
|
|
}
|
|
|
|
const temperatureText = fmtTemperature(node?.temperature);
|
|
if (temperatureText) {
|
|
lines.push(`Temperature: ${temperatureText}`);
|
|
}
|
|
const humidityText = fmtHumidity(node?.relative_humidity);
|
|
if (humidityText) {
|
|
lines.push(`Humidity: ${humidityText}`);
|
|
}
|
|
const pressureText = fmtPressure(node?.barometric_pressure);
|
|
if (pressureText) {
|
|
lines.push(`Pressure: ${pressureText}`);
|
|
}
|
|
|
|
const lastHeardNum = Number(node?.last_heard);
|
|
if (Number.isFinite(lastHeardNum) && lastHeardNum > 0) {
|
|
lines.push(`Last seen: ${timeAgo(lastHeardNum, nowSec)}`);
|
|
}
|
|
|
|
const uptimeNum = Number(node?.uptime_seconds);
|
|
if (Number.isFinite(uptimeNum) && uptimeNum > 0) {
|
|
lines.push(`Uptime: ${timeHum(uptimeNum)}`);
|
|
}
|
|
|
|
const overlayNeighbors = Array.isArray(node?.neighbors) ? node.neighbors : [];
|
|
const neighborEntries = overlayNeighbors.length
|
|
? overlayNeighbors
|
|
: getNeighborNodesFor(node?.node_id ?? '');
|
|
if (neighborEntries.length) {
|
|
const neighborParts = neighborEntries
|
|
.map(renderNeighborWithSnrHtml)
|
|
.filter(html => html && html.length);
|
|
if (neighborParts.length) {
|
|
lines.push(`Neighbors: ${neighborParts.join(' ')}`);
|
|
}
|
|
}
|
|
|
|
return lines.join('<br/>');
|
|
}
|
|
|
|
/**
|
|
* Format uptime values for the short-info overlay.
|
|
*
|
|
* @param {*} value Raw uptime value.
|
|
* @returns {string} Human readable uptime string.
|
|
*/
|
|
function formatShortInfoUptime(value) {
|
|
if (value == null || value === '') return '';
|
|
const num = Number(value);
|
|
if (!Number.isFinite(num)) return '';
|
|
return num === 0 ? '0s' : timeHum(num);
|
|
}
|
|
|
|
/**
|
|
* Format overlay values with an em dash fallback when blank.
|
|
*
|
|
* @param {*} value Candidate value.
|
|
* @returns {string} Formatted value or em dash.
|
|
*/
|
|
function shortInfoValueOrDash(value) {
|
|
return value != null && value !== '' ? String(value) : '—';
|
|
}
|
|
|
|
/**
|
|
* Transform a node-shaped payload into the overlay data format.
|
|
*
|
|
* @param {*} source Arbitrary node data.
|
|
* @returns {Object} Normalized overlay payload.
|
|
*/
|
|
function normalizeOverlaySource(source) {
|
|
if (!source || typeof source !== 'object') return {};
|
|
const normalized = {};
|
|
const nodeIdRaw = source.nodeId ?? source.node_id;
|
|
if (typeof nodeIdRaw === 'string' && nodeIdRaw.trim().length > 0) {
|
|
normalized.nodeId = nodeIdRaw.trim();
|
|
}
|
|
const nodeNumRaw = source.nodeNum ?? source.node_num ?? source.num;
|
|
const nodeNumParsed = Number(nodeNumRaw);
|
|
if (Number.isFinite(nodeNumParsed)) {
|
|
normalized.nodeNum = nodeNumParsed;
|
|
}
|
|
const shortRaw = source.shortName ?? source.short_name;
|
|
if (shortRaw != null && String(shortRaw).trim().length > 0) {
|
|
normalized.shortName = String(shortRaw).trim();
|
|
}
|
|
const longRaw = source.longName ?? source.long_name;
|
|
if (longRaw != null && String(longRaw).trim().length > 0) {
|
|
normalized.longName = String(longRaw).trim();
|
|
}
|
|
if (source.role && String(source.role).trim().length > 0) {
|
|
normalized.role = String(source.role).trim();
|
|
}
|
|
if (source.hwModel ?? source.hw_model) {
|
|
normalized.hwModel = source.hwModel ?? source.hw_model;
|
|
}
|
|
|
|
const modemMetadata = extractModemMetadata(source);
|
|
if (modemMetadata.modemPreset) {
|
|
normalized.modemPreset = modemMetadata.modemPreset;
|
|
}
|
|
if (modemMetadata.loraFreq != null) {
|
|
normalized.loraFreq = modemMetadata.loraFreq;
|
|
}
|
|
|
|
const numericPairs = [
|
|
['telemetryTime', source.telemetryTime ?? source.telemetry_time],
|
|
['lastHeard', source.lastHeard ?? source.last_heard],
|
|
['latitude', source.latitude],
|
|
['longitude', source.longitude],
|
|
['altitude', source.altitude],
|
|
['satsInView', source.satsInView ?? source.sats_in_view],
|
|
['positionTime', source.positionTime ?? source.position_time],
|
|
];
|
|
for (const [key, value] of numericPairs) {
|
|
if (value == null || value === '') continue;
|
|
const num = Number(value);
|
|
if (Number.isFinite(num)) {
|
|
normalized[key] = num;
|
|
}
|
|
}
|
|
|
|
const telemetryMetrics = collectTelemetryMetrics(source);
|
|
for (const field of TELEMETRY_FIELDS) {
|
|
if (!Object.prototype.hasOwnProperty.call(telemetryMetrics, field.key)) {
|
|
continue;
|
|
}
|
|
normalized[field.key] = telemetryMetrics[field.key];
|
|
}
|
|
|
|
const lastSeenRaw = source.lastSeenIso ?? source.last_seen_iso;
|
|
if (typeof lastSeenRaw === 'string' && lastSeenRaw.trim().length > 0) {
|
|
normalized.lastSeenIso = lastSeenRaw.trim();
|
|
}
|
|
const positionIsoRaw = source.positionTimeIso ?? source.position_time_iso;
|
|
if (typeof positionIsoRaw === 'string' && positionIsoRaw.trim().length > 0) {
|
|
normalized.positionTimeIso = positionIsoRaw.trim();
|
|
}
|
|
|
|
if (Array.isArray(source.neighbors)) {
|
|
const overlayNeighbors = overlayToPopupNode({ neighbors: source.neighbors }).neighbors;
|
|
if (overlayNeighbors.length) {
|
|
normalized.neighbors = overlayNeighbors;
|
|
}
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
/**
|
|
* Combine primary and fallback node information into an overlay payload.
|
|
*
|
|
* @param {*} primary Primary node details (e.g. fetched from the API).
|
|
* @param {*} fallback Fallback node details rendered with the page.
|
|
* @returns {Object} Overlay payload ready for rendering.
|
|
*/
|
|
function mergeOverlayDetails(primary, fallback) {
|
|
const fallbackNormalized = normalizeOverlaySource(fallback);
|
|
const primaryNormalized = normalizeOverlaySource(primary);
|
|
const merged = { ...fallbackNormalized, ...primaryNormalized };
|
|
const neighborList = primaryNormalized.neighbors ?? fallbackNormalized.neighbors;
|
|
if (neighborList) {
|
|
merged.neighbors = neighborList;
|
|
}
|
|
if (!merged.role || merged.role === '') {
|
|
merged.role = 'CLIENT';
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
/**
|
|
* Display a temporary loading state while node details are fetched.
|
|
*
|
|
* @param {HTMLElement} target Anchor element associated with the overlay.
|
|
* @param {Object} [info] Optional fallback information describing the node.
|
|
* @returns {void}
|
|
*/
|
|
function showShortInfoLoading(target, info) {
|
|
if (!target) return;
|
|
const normalized = normalizeOverlaySource(info || {});
|
|
const heading = normalized.longName || normalized.shortName || normalized.nodeId || '';
|
|
let headingHtml = '';
|
|
if (normalized.longName) {
|
|
const link = renderNodeLongNameLink(normalized.longName, normalized.nodeId);
|
|
if (link) {
|
|
headingHtml = `<strong>${link}</strong><br/>`;
|
|
}
|
|
}
|
|
if (!headingHtml && heading) {
|
|
headingHtml = `<strong>${escapeHtml(heading)}</strong><br/>`;
|
|
}
|
|
overlayStack.render(target, `${headingHtml}Loading…`);
|
|
}
|
|
|
|
/**
|
|
* Populate and display the short-info overlay for a node badge.
|
|
*
|
|
* @param {HTMLElement} target Anchor element that triggered the overlay.
|
|
* @param {Object} info Node payload displayed in the overlay.
|
|
* @returns {void}
|
|
*/
|
|
function openShortInfoOverlay(target, info) {
|
|
if (!target || !info) return;
|
|
const overlayInfo = normalizeOverlaySource(info);
|
|
if (!overlayInfo.role || overlayInfo.role === '') {
|
|
overlayInfo.role = 'CLIENT';
|
|
}
|
|
const lines = [];
|
|
const longNameLink = renderNodeLongNameLink(overlayInfo.longName, overlayInfo.nodeId);
|
|
if (longNameLink) {
|
|
lines.push(`<strong>${longNameLink}</strong>`);
|
|
} else {
|
|
const longNameValue = shortInfoValueOrDash(overlayInfo.longName ?? '');
|
|
if (longNameValue !== '—') {
|
|
lines.push(`<strong>${escapeHtml(longNameValue)}</strong>`);
|
|
}
|
|
}
|
|
const shortParts = [];
|
|
const shortHtml = renderShortHtml(overlayInfo.shortName, overlayInfo.role, overlayInfo.longName);
|
|
if (shortHtml) {
|
|
shortParts.push(shortHtml);
|
|
}
|
|
const nodeIdValue = shortInfoValueOrDash(overlayInfo.nodeId ?? '');
|
|
if (nodeIdValue !== '—') {
|
|
shortParts.push(`<span class="mono">${escapeHtml(nodeIdValue)}</span>`);
|
|
}
|
|
const satelliteLine = renderSatsInViewBadge(overlayInfo);
|
|
if (satelliteLine) {
|
|
shortParts.push(satelliteLine);
|
|
}
|
|
if (shortParts.length) {
|
|
lines.push(shortParts.join(' '));
|
|
}
|
|
const modemDisplay = formatModemDisplay(overlayInfo.modemPreset, overlayInfo.loraFreq);
|
|
if (modemDisplay) {
|
|
lines.push(escapeHtml(modemDisplay));
|
|
}
|
|
const roleValue = shortInfoValueOrDash(overlayInfo.role || 'CLIENT');
|
|
if (roleValue !== '—') {
|
|
lines.push(`Role: ${escapeHtml(roleValue)}`);
|
|
}
|
|
let neighborLineHtml = '';
|
|
const neighborEntries = Array.isArray(overlayInfo.neighbors) && overlayInfo.neighbors.some(entry => entry && entry.node)
|
|
? overlayInfo.neighbors
|
|
: getNeighborNodesFor(overlayInfo.nodeId);
|
|
if (neighborEntries.length) {
|
|
const neighborParts = neighborEntries
|
|
.map(renderNeighborWithSnrHtml)
|
|
.filter(html => html && html.length);
|
|
if (neighborParts.length) {
|
|
neighborLineHtml = `Neighbors: ${neighborParts.join(' ')}`;
|
|
}
|
|
}
|
|
const modelValue = fmtHw(overlayInfo.hwModel);
|
|
if (modelValue) {
|
|
lines.push(`Model: ${escapeHtml(modelValue)}`);
|
|
}
|
|
const telemetryEntries = buildTelemetryDisplayEntries(overlayInfo, { formatUptime: formatShortInfoUptime });
|
|
for (const entry of telemetryEntries) {
|
|
lines.push(`${escapeHtml(entry.label)}: ${escapeHtml(entry.value)}`);
|
|
}
|
|
if (neighborLineHtml) {
|
|
lines.push(neighborLineHtml);
|
|
}
|
|
overlayStack.render(target, lines.join('<br/>'));
|
|
}
|
|
|
|
/**
|
|
* Display an overlay describing a neighbour link.
|
|
*
|
|
* @param {HTMLElement} target Anchor element for the overlay.
|
|
* @param {Object} segment GeoJSON segment describing the connection.
|
|
* @returns {void}
|
|
*/
|
|
function openNeighborOverlay(target, segment) {
|
|
if (!target || !segment) return;
|
|
const nodeName = shortInfoValueOrDash(segment.sourceDisplayName || segment.sourceId || '');
|
|
const snrText = shortInfoValueOrDash(formatSnrDisplay(segment.snr));
|
|
const sourceShortHtml = renderShortHtml(
|
|
segment.sourceShortName,
|
|
segment.sourceRole,
|
|
segment.sourceDisplayName
|
|
);
|
|
const targetShortHtml = renderShortHtml(
|
|
segment.targetShortName,
|
|
segment.targetRole,
|
|
segment.targetDisplayName
|
|
);
|
|
const sourceIdText = shortInfoValueOrDash(segment.sourceId || '');
|
|
const neighborFullName = shortInfoValueOrDash(segment.targetDisplayName || segment.targetId || '');
|
|
const lines = [];
|
|
const sourceLongLink = renderNodeLongNameLink(segment.sourceDisplayName, segment.sourceId);
|
|
if (sourceLongLink) {
|
|
lines.push(`<strong>${sourceLongLink}</strong>`);
|
|
} else {
|
|
lines.push(`<strong>${escapeHtml(nodeName)}</strong>`);
|
|
}
|
|
lines.push(`${sourceShortHtml} <span class="mono">${escapeHtml(sourceIdText)}</span>`);
|
|
const neighborLongLink = renderNodeLongNameLink(segment.targetDisplayName, segment.targetId);
|
|
const neighborLabel = neighborLongLink || escapeHtml(neighborFullName);
|
|
const neighborLine = `${targetShortHtml} [${neighborLabel}]`;
|
|
lines.push(neighborLine);
|
|
lines.push(`SNR: ${escapeHtml(snrText)}`);
|
|
overlayStack.render(target, lines.join('<br/>'));
|
|
}
|
|
|
|
/**
|
|
* Create a chat log date divider when the day changes.
|
|
*
|
|
* @param {number} ts Unix timestamp in seconds.
|
|
* @returns {HTMLElement} Divider element.
|
|
*/
|
|
function createDateDividerFactory() {
|
|
let lastChatDate = null;
|
|
return ts => {
|
|
if (!ts) return null;
|
|
const d = new Date(ts * 1000);
|
|
const key = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
if (lastChatDate !== key) {
|
|
lastChatDate = key;
|
|
const midnight = new Date(d);
|
|
midnight.setHours(0, 0, 0, 0);
|
|
const div = document.createElement('div');
|
|
div.className = 'chat-entry-date';
|
|
div.textContent = `-- ${formatDate(midnight)} --`;
|
|
return div;
|
|
}
|
|
return null;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build a chat log entry describing a node join event.
|
|
*
|
|
* @param {Object} n Node payload.
|
|
* @returns {HTMLElement} Chat log element.
|
|
*/
|
|
function createNodeChatEntry(node, timestampOverride = null) {
|
|
if (!node || typeof node !== 'object') return null;
|
|
const nodeIdRaw = pickFirstProperty([node], ['node_id', 'nodeId']);
|
|
const fallbackId = nodeIdRaw || 'Unknown node';
|
|
const longNameRaw = pickFirstProperty([node], ['long_name', 'longName']);
|
|
const longNameDisplay = longNameRaw ? String(longNameRaw) : fallbackId;
|
|
const longNameLink = renderNodeLongNameLink(longNameRaw, nodeIdRaw);
|
|
const announcementName = longNameLink || escapeHtml(longNameDisplay);
|
|
const shortNameRaw = pickFirstProperty([node], ['short_name', 'shortName']);
|
|
const shortNameDisplay = shortNameRaw ? String(shortNameRaw) : (nodeIdRaw ? nodeIdRaw.slice(-4) : null);
|
|
const roleDisplay = pickFirstProperty([node], ['role']);
|
|
const tsSeconds = timestampOverride != null
|
|
? timestampOverride
|
|
: resolveTimestampSeconds(node.first_heard ?? node.firstHeard, node.first_heard_iso ?? node.firstHeardIso);
|
|
return createAnnouncementEntry({
|
|
timestampSeconds: tsSeconds,
|
|
shortName: shortNameDisplay,
|
|
longName: longNameDisplay,
|
|
role: roleDisplay,
|
|
metadataSource: node,
|
|
nodeData: node,
|
|
messageHtml: `${renderEmojiHtml('☀️')} ${renderAnnouncementCopy('New node:', ` ${announcementName}`)}`
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Build a formatted suffix that enumerates highlight values.
|
|
*
|
|
* @param {Array<{label: string, value: string}>} highlights Highlight metadata entries.
|
|
* @returns {string} HTML suffix containing escaped highlight entries.
|
|
*/
|
|
function buildHighlightSuffix(highlights) {
|
|
if (!Array.isArray(highlights) || highlights.length === 0) {
|
|
return '';
|
|
}
|
|
const parts = [];
|
|
for (const entry of highlights) {
|
|
if (!entry || typeof entry !== 'object') {
|
|
continue;
|
|
}
|
|
const { label, value } = entry;
|
|
if (label == null || value == null || value === '') {
|
|
continue;
|
|
}
|
|
const labelText = String(label).trim();
|
|
const valueText = String(value).trim();
|
|
if (!labelText || !valueText) {
|
|
continue;
|
|
}
|
|
parts.push(`${escapeHtml(labelText)}: ${escapeHtml(valueText)}`);
|
|
}
|
|
if (!parts.length) {
|
|
return '';
|
|
}
|
|
return ` — ${parts.join(', ')}`;
|
|
}
|
|
|
|
/**
|
|
* Render a non-italicised emoji span suitable for announcement entries.
|
|
*
|
|
* @param {string} symbol Emoji or short textual marker.
|
|
* @returns {string} HTML span wrapping the escaped symbol.
|
|
*/
|
|
function renderEmojiHtml(symbol) {
|
|
if (symbol == null) {
|
|
return '';
|
|
}
|
|
const trimmed = String(symbol).trim();
|
|
if (!trimmed) {
|
|
return '';
|
|
}
|
|
return `<span class="chat-entry-emoji" aria-hidden="true">${escapeHtml(trimmed)}</span>`;
|
|
}
|
|
|
|
/**
|
|
* Render chat announcement copy without italic styling.
|
|
*
|
|
* @param {string} baseText Base message content before any suffix.
|
|
* @param {string} [suffix=''] Optional HTML-safe suffix appended to the base copy.
|
|
* @returns {string} Escaped HTML span containing the announcement copy.
|
|
*/
|
|
function renderAnnouncementCopy(baseText, suffix = '') {
|
|
const safeBase = baseText != null ? String(baseText) : '';
|
|
const safeSuffix = suffix != null ? String(suffix) : '';
|
|
return `<span class="chat-entry-copy">${escapeHtml(safeBase)}${safeSuffix}</span>`;
|
|
}
|
|
|
|
function createNodeInfoChatEntry(entry, context) {
|
|
const label = context.longName ? String(context.longName) : (context.nodeId || 'Unknown node');
|
|
return createAnnouncementEntry({
|
|
timestampSeconds: entry?.ts ?? null,
|
|
shortName: context.shortName,
|
|
longName: label,
|
|
role: context.role,
|
|
metadataSource: context.metadataSource,
|
|
nodeData: context.nodeData,
|
|
messageHtml: `${renderEmojiHtml('💾')} ${renderAnnouncementCopy('Updated node info')}`
|
|
});
|
|
}
|
|
|
|
function createTelemetryChatEntry(entry, context) {
|
|
const label = context.longName ? String(context.longName) : (context.nodeId || 'Unknown node');
|
|
const highlightSuffix = buildHighlightSuffix(formatTelemetryHighlights(entry?.telemetry));
|
|
return createAnnouncementEntry({
|
|
timestampSeconds: entry?.ts ?? null,
|
|
shortName: context.shortName,
|
|
longName: label,
|
|
role: context.role,
|
|
metadataSource: context.metadataSource,
|
|
nodeData: context.nodeData,
|
|
messageHtml: `${renderEmojiHtml('🔋')} ${renderAnnouncementCopy('Broadcasted telemetry', highlightSuffix)}`
|
|
});
|
|
}
|
|
|
|
function createPositionChatEntry(entry, context) {
|
|
const label = context.longName ? String(context.longName) : (context.nodeId || 'Unknown node');
|
|
const highlightSuffix = buildHighlightSuffix(formatPositionHighlights(entry?.position));
|
|
return createAnnouncementEntry({
|
|
timestampSeconds: entry?.ts ?? null,
|
|
shortName: context.shortName,
|
|
longName: label,
|
|
role: context.role,
|
|
metadataSource: context.metadataSource,
|
|
nodeData: context.nodeData,
|
|
messageHtml: `${renderEmojiHtml('📍')} ${renderAnnouncementCopy('Broadcasted position info', highlightSuffix)}`
|
|
});
|
|
}
|
|
|
|
function createNeighborChatEntry(entry, context) {
|
|
const label = context.longName ? String(context.longName) : (context.nodeId || 'Unknown node');
|
|
const neighborId = entry?.neighborId ?? pickFirstProperty([entry?.neighbor], ['neighbor_id', 'neighborId']);
|
|
let neighborLabel = null;
|
|
if (neighborId) {
|
|
const trimmed = String(neighborId).trim();
|
|
if (trimmed && nodesById.has(trimmed)) {
|
|
const neighborNode = nodesById.get(trimmed);
|
|
neighborLabel = pickFirstProperty([neighborNode], ['long_name', 'longName', 'short_name', 'shortName']) ?? trimmed;
|
|
} else {
|
|
neighborLabel = trimmed;
|
|
}
|
|
}
|
|
const detail = neighborLabel ? `: ${escapeHtml(String(neighborLabel))}` : '';
|
|
return createAnnouncementEntry({
|
|
timestampSeconds: entry?.ts ?? null,
|
|
shortName: context.shortName,
|
|
longName: label,
|
|
role: context.role,
|
|
metadataSource: context.metadataSource,
|
|
nodeData: context.nodeData,
|
|
messageHtml: `${renderEmojiHtml('🏘️')} ${renderAnnouncementCopy('Broadcasted neighbor info', detail)}`
|
|
});
|
|
}
|
|
|
|
function createChatLogEntry(entry) {
|
|
if (!entry || typeof entry !== 'object') return null;
|
|
if (entry.type === CHAT_LOG_ENTRY_TYPES.NODE_NEW) {
|
|
return createNodeChatEntry(entry.node ?? resolveNodeForLogEntry(entry) ?? null, entry?.ts ?? null);
|
|
}
|
|
const context = buildDisplayContext(entry);
|
|
switch (entry.type) {
|
|
case CHAT_LOG_ENTRY_TYPES.NODE_INFO:
|
|
return createNodeInfoChatEntry(entry, context);
|
|
case CHAT_LOG_ENTRY_TYPES.TELEMETRY:
|
|
return createTelemetryChatEntry(entry, context);
|
|
case CHAT_LOG_ENTRY_TYPES.POSITION:
|
|
return createPositionChatEntry(entry, context);
|
|
case CHAT_LOG_ENTRY_TYPES.NEIGHBOR:
|
|
return createNeighborChatEntry(entry, context);
|
|
case CHAT_LOG_ENTRY_TYPES.TRACE:
|
|
return createTraceChatEntry(entry, context);
|
|
case CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED:
|
|
return entry?.message ? createMessageChatEntry(entry.message) : null;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a consistently formatted chat log entry for node-centric events.
|
|
*
|
|
* @param {{
|
|
* timestampSeconds: ?number,
|
|
* shortName: ?string,
|
|
* longName: ?string,
|
|
* role: ?string,
|
|
* metadataSource: Object|null,
|
|
* nodeData: Object|null,
|
|
* messageHtml: string
|
|
* }} params Rendering parameters.
|
|
* @returns {HTMLElement} Chat log element.
|
|
*/
|
|
function createAnnouncementEntry({
|
|
timestampSeconds,
|
|
shortName,
|
|
longName,
|
|
role,
|
|
metadataSource,
|
|
nodeData,
|
|
messageHtml
|
|
}) {
|
|
const div = document.createElement('div');
|
|
const tsDate = timestampSeconds != null ? new Date(timestampSeconds * 1000) : null;
|
|
const ts = tsDate ? formatTime(tsDate) : '--:--:--';
|
|
const metadata = extractChatMessageMetadata(metadataSource || nodeData || {});
|
|
const prefix = formatNodeAnnouncementPrefix({
|
|
timestamp: escapeHtml(ts),
|
|
frequency: metadata.frequency ? escapeHtml(metadata.frequency) : ''
|
|
});
|
|
const presetTag = formatChatPresetTag({ presetCode: metadata.presetCode });
|
|
const longNameDisplay = longName != null ? String(longName) : '';
|
|
const shortHtml = renderShortHtml(shortName, role, longNameDisplay, nodeData || metadataSource || {});
|
|
div.className = 'chat-entry-node';
|
|
div.innerHTML = `${prefix}${presetTag} ${shortHtml} ${messageHtml}`;
|
|
return div;
|
|
}
|
|
|
|
/**
|
|
* Convert a trace path into user-friendly labels using cached node metadata.
|
|
*
|
|
* @param {Array<{id: ?string, num: ?number, raw: *}>} tracePath Ordered hop references.
|
|
* @returns {Array<string>} Display labels for each hop.
|
|
*/
|
|
function formatTracePathLabels(tracePath) {
|
|
if (!Array.isArray(tracePath)) return [];
|
|
const labels = [];
|
|
for (const hop of tracePath) {
|
|
if (!hop || typeof hop !== 'object') continue;
|
|
const node = resolveNodeForHop(hop);
|
|
const fallbackId = hop.id ?? (Number.isFinite(hop.num) ? String(hop.num) : (hop.raw != null ? String(hop.raw) : ''));
|
|
const shortName = node ? normalizeNodeNameValue(node.short_name ?? node.shortName) : null;
|
|
const label = shortName || (node ? (getNodeDisplayNameForOverlay(node) || fallbackId) : fallbackId);
|
|
if (label) {
|
|
labels.push(String(label));
|
|
}
|
|
}
|
|
return labels;
|
|
}
|
|
|
|
function createTraceChatEntry(entry, context) {
|
|
if (!entry || !Array.isArray(entry.tracePath) || entry.tracePath.length < 2) {
|
|
return null;
|
|
}
|
|
const sourceHop = entry.tracePath[0] || null;
|
|
const sourceNode = resolveNodeForHop(sourceHop);
|
|
const labels = formatTracePathLabels(entry.tracePath);
|
|
const labelText = labels.length ? labels.join(', ') : 'Traceroute';
|
|
const labelSuffix = `: ${escapeHtml(labelText)}`;
|
|
return createAnnouncementEntry({
|
|
timestampSeconds: entry?.ts ?? null,
|
|
shortName: context.shortName,
|
|
longName: context.longName || context.nodeId || labels[0] || 'Traceroute',
|
|
role: context.role,
|
|
metadataSource: sourceNode || context.metadataSource,
|
|
nodeData: sourceNode || context.nodeData,
|
|
messageHtml: `${renderEmojiHtml('👣')} ${renderAnnouncementCopy('Caught trace', labelSuffix)}`
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Build tooltip HTML showing styled short-name badges for a trace path.
|
|
*
|
|
* @param {Array<Object>} pathNodes Ordered node payloads along the trace.
|
|
* @returns {string} HTML fragment or ``''`` when unavailable.
|
|
*/
|
|
function buildTraceTooltipHtml(pathNodes) {
|
|
if (!Array.isArray(pathNodes) || pathNodes.length < 2) {
|
|
return '';
|
|
}
|
|
const parts = pathNodes
|
|
.map(node => {
|
|
if (!node || typeof node !== 'object') {
|
|
return null;
|
|
}
|
|
const short = normalizeNodeNameValue(node.short_name ?? node.shortName) || (typeof node.node_id === 'string' ? node.node_id : '');
|
|
const long = normalizeNodeNameValue(node.long_name ?? node.longName) || '';
|
|
return renderShortHtml(short, node.role, long, node);
|
|
})
|
|
.filter(Boolean);
|
|
if (!parts.length) return '';
|
|
const arrow = '<span class="trace-tooltip__arrow" aria-hidden="true">→</span>';
|
|
return `<div class="trace-tooltip__content">${parts.join(arrow)}</div>`;
|
|
}
|
|
|
|
/**
|
|
* Build tooltip HTML for a neighbor segment showing styled short-name badges.
|
|
*
|
|
* @param {{sourceNode?: Object, targetNode?: Object, sourceShortName?: string, targetShortName?: string, sourceRole?: string, targetRole?: string}} segment Neighbor segment descriptor.
|
|
* @returns {string} HTML fragment or ``''`` when unavailable.
|
|
*/
|
|
function buildNeighborTooltipHtml(segment) {
|
|
if (!segment) return '';
|
|
const sourceNode = segment.sourceNode || null;
|
|
const targetNode = segment.targetNode || null;
|
|
const sourceShort = normalizeNodeNameValue(
|
|
segment.sourceShortName ||
|
|
(sourceNode ? sourceNode.short_name ?? sourceNode.shortName : null) ||
|
|
(sourceNode && typeof sourceNode.node_id === 'string' ? sourceNode.node_id : '')
|
|
);
|
|
const targetShort = normalizeNodeNameValue(
|
|
segment.targetShortName ||
|
|
(targetNode ? targetNode.short_name ?? targetNode.shortName : null) ||
|
|
(targetNode && typeof targetNode.node_id === 'string' ? targetNode.node_id : '')
|
|
);
|
|
if (!sourceShort || !targetShort) return '';
|
|
const sourceLong = normalizeNodeNameValue(sourceNode?.long_name ?? sourceNode?.longName) || '';
|
|
const targetLong = normalizeNodeNameValue(targetNode?.long_name ?? targetNode?.longName) || '';
|
|
const sourceHtml = renderShortHtml(sourceShort, segment.sourceRole, sourceLong, sourceNode || {});
|
|
const targetHtml = renderShortHtml(targetShort, segment.targetRole, targetLong, targetNode || {});
|
|
const arrow = '<span class="trace-tooltip__arrow" aria-hidden="true">→</span>';
|
|
return `<div class="trace-tooltip__content">${sourceHtml}${arrow}${targetHtml}</div>`;
|
|
}
|
|
|
|
/**
|
|
* Resolve a node reference for a trace hop using cached node indices.
|
|
*
|
|
* @param {{id?: string, num?: number}|null} hop Trace hop descriptor.
|
|
* @returns {?Object} Node payload when available.
|
|
*/
|
|
function resolveNodeForHop(hop) {
|
|
if (!hop || typeof hop !== 'object') {
|
|
return null;
|
|
}
|
|
const id = typeof hop.id === 'string' ? hop.id.trim() : null;
|
|
const idCandidates = [];
|
|
if (id) {
|
|
idCandidates.push(id);
|
|
idCandidates.push(id.toUpperCase());
|
|
idCandidates.push(id.toLowerCase());
|
|
}
|
|
for (const candidate of idCandidates) {
|
|
if (candidate && nodesById instanceof Map && nodesById.has(candidate)) {
|
|
return nodesById.get(candidate);
|
|
}
|
|
}
|
|
const numericCandidates = [];
|
|
if (Number.isFinite(hop.num)) numericCandidates.push(hop.num);
|
|
const parsedFromId = parseNodeNumericRef(id);
|
|
if (parsedFromId != null) numericCandidates.push(parsedFromId);
|
|
const parsedFromNum = parseNodeNumericRef(hop.num);
|
|
if (parsedFromNum != null) numericCandidates.push(parsedFromNum);
|
|
for (const numeric of numericCandidates) {
|
|
if (Number.isFinite(numeric) && nodesByNum instanceof Map && nodesByNum.has(numeric)) {
|
|
return nodesByNum.get(numeric);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Derive display context for a chat log entry by inspecting node payloads.
|
|
*
|
|
* @param {Object} entry Chat log entry payload.
|
|
* @returns {{
|
|
* nodeId: ?string,
|
|
* nodeNum: ?number,
|
|
* shortName: ?string,
|
|
* longName: ?string,
|
|
* role: ?string,
|
|
* metadataSource: Object|null,
|
|
* nodeData: Object|null
|
|
* }} Normalised display metadata.
|
|
*/
|
|
function buildDisplayContext(entry) {
|
|
const resolvedNode = resolveNodeForLogEntry(entry);
|
|
const candidateSources = [resolvedNode, entry?.node, entry?.telemetry, entry?.position, entry?.neighbor]
|
|
.filter(source => source && typeof source === 'object');
|
|
const nodeId = typeof entry?.nodeId === 'string' && entry.nodeId.trim().length
|
|
? entry.nodeId.trim()
|
|
: pickFirstProperty(candidateSources, ['node_id', 'nodeId']);
|
|
const nodeNum = Number.isFinite(entry?.nodeNum)
|
|
? entry.nodeNum
|
|
: pickNumericProperty(candidateSources, ['node_num', 'nodeNum', 'num']);
|
|
let shortName = pickFirstProperty(candidateSources, ['short_name', 'shortName']);
|
|
if ((!shortName || String(shortName).trim().length === 0) && nodeId) {
|
|
shortName = nodeId.slice(-4);
|
|
}
|
|
let longName = pickFirstProperty(candidateSources, ['long_name', 'longName']);
|
|
if ((!longName || String(longName).trim().length === 0) && nodeId) {
|
|
longName = nodeId;
|
|
}
|
|
const role = pickFirstProperty(candidateSources, ['role']);
|
|
const metadataSource = resolvedNode || candidateSources[0] || {};
|
|
const nodeData = resolvedNode || candidateSources[0] || {};
|
|
return { nodeId, nodeNum, shortName, longName, role, metadataSource, nodeData };
|
|
}
|
|
|
|
/**
|
|
* Locate the canonical node object associated with a chat log entry.
|
|
*
|
|
* @param {Object} entry Chat log entry payload.
|
|
* @returns {?Object} Matched node payload when available.
|
|
*/
|
|
function resolveNodeForLogEntry(entry) {
|
|
if (!entry || typeof entry !== 'object') return null;
|
|
if (entry.node && typeof entry.node === 'object') {
|
|
return entry.node;
|
|
}
|
|
const idCandidates = [];
|
|
if (typeof entry?.nodeId === 'string') {
|
|
idCandidates.push(entry.nodeId);
|
|
}
|
|
for (const source of [entry?.node, entry?.telemetry, entry?.position, entry?.neighbor]) {
|
|
const candidate = pickFirstProperty([source], ['node_id', 'nodeId']);
|
|
if (candidate) {
|
|
idCandidates.push(candidate);
|
|
}
|
|
}
|
|
for (const rawId of idCandidates) {
|
|
const trimmed = typeof rawId === 'string' ? rawId.trim() : String(rawId);
|
|
if (trimmed && nodesById.has(trimmed)) {
|
|
return nodesById.get(trimmed);
|
|
}
|
|
}
|
|
const numCandidates = [];
|
|
if (Number.isFinite(entry?.nodeNum)) {
|
|
numCandidates.push(entry.nodeNum);
|
|
}
|
|
for (const source of [entry?.node, entry?.telemetry, entry?.position, entry?.neighbor]) {
|
|
const candidate = pickNumericProperty([source], ['node_num', 'nodeNum', 'num']);
|
|
if (Number.isFinite(candidate)) {
|
|
numCandidates.push(candidate);
|
|
}
|
|
}
|
|
for (const num of numCandidates) {
|
|
if (Number.isFinite(num) && nodesByNum.has(num)) {
|
|
return nodesByNum.get(num);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Retrieve the first present property value from a collection of objects.
|
|
*
|
|
* @param {Array<Object>} sources Candidate objects.
|
|
* @param {Array<string>} keys Ordered property names to inspect.
|
|
* @returns {*} First present non-blank value or ``null`` when absent.
|
|
*/
|
|
function pickFirstProperty(sources, keys) {
|
|
if (!Array.isArray(sources) || !Array.isArray(keys)) {
|
|
return null;
|
|
}
|
|
for (const source of sources) {
|
|
if (!source || typeof source !== 'object') continue;
|
|
for (const key of keys) {
|
|
if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
|
|
const value = source[key];
|
|
if (value == null) continue;
|
|
if (typeof value === 'string') {
|
|
const trimmed = value.trim();
|
|
if (trimmed.length === 0) {
|
|
continue;
|
|
}
|
|
return trimmed;
|
|
}
|
|
return value;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Retrieve the first finite numeric property from candidate objects.
|
|
*
|
|
* @param {Array<Object>} sources Candidate objects.
|
|
* @param {Array<string>} keys Ordered property names to inspect.
|
|
* @returns {?number} First finite number when available.
|
|
*/
|
|
function pickNumericProperty(sources, keys) {
|
|
if (!Array.isArray(sources) || !Array.isArray(keys)) {
|
|
return null;
|
|
}
|
|
for (const source of sources) {
|
|
if (!source || typeof source !== 'object') continue;
|
|
for (const key of keys) {
|
|
if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
|
|
const raw = source[key];
|
|
if (raw == null || raw === '') continue;
|
|
const num = typeof raw === 'number' ? raw : Number(raw);
|
|
if (Number.isFinite(num)) {
|
|
return num;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Describe an encrypted message when the payload cannot be decrypted.
|
|
*
|
|
* @param {Object} message Raw message payload.
|
|
* @returns {{content: string, isHtml: boolean}} Renderable notice payload.
|
|
*/
|
|
function formatEncryptedMessageNotice(message) {
|
|
const recipient = pickFirstProperty([message], ['to_id', 'toId']);
|
|
const recipientText = recipient != null && recipient !== ''
|
|
? String(recipient).trim()
|
|
: '';
|
|
if (recipientText && recipientText.toLowerCase() !== '^all') {
|
|
const targetNode = resolveRecipientNode(recipientText);
|
|
if (targetNode) {
|
|
const badge = renderShortHtml(
|
|
targetNode.short_name ?? targetNode.shortName,
|
|
targetNode.role,
|
|
targetNode.long_name ?? targetNode.longName,
|
|
targetNode
|
|
);
|
|
const idSpan = `<span class="mono">${escapeHtml(recipientText)}</span>`;
|
|
return { content: `🔒 encrypted message to ${badge} ${idSpan}`, isHtml: true };
|
|
}
|
|
return { content: `🔒 encrypted message to ${recipientText}`, isHtml: false };
|
|
}
|
|
|
|
const channelCandidate = pickFirstProperty([message], ['channel', 'channel_index', 'channelIndex']);
|
|
let channelLabel = null;
|
|
if (channelCandidate != null && channelCandidate !== '') {
|
|
if (typeof channelCandidate === 'number' && Number.isFinite(channelCandidate)) {
|
|
channelLabel = String(Math.round(channelCandidate));
|
|
} else {
|
|
const trimmedChannel = String(channelCandidate).trim();
|
|
if (trimmedChannel.length > 0) {
|
|
const numericChannel = Number(trimmedChannel);
|
|
channelLabel = Number.isFinite(numericChannel) ? String(Math.round(numericChannel)) : trimmedChannel;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!channelLabel) {
|
|
const channelName = pickFirstProperty([message], ['channel_name', 'channelName']);
|
|
if (channelName != null) {
|
|
const trimmedName = String(channelName).trim();
|
|
if (trimmedName.length > 0) {
|
|
channelLabel = trimmedName;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!channelLabel) {
|
|
channelLabel = 'unknown channel';
|
|
}
|
|
|
|
return { content: `🔒 encrypted message on channel ${channelLabel}`, isHtml: false };
|
|
}
|
|
|
|
/**
|
|
* Resolve a recipient node using identifier or numeric references.
|
|
*
|
|
* @param {string} recipientId Target node reference.
|
|
* @returns {?Object} Node metadata when available.
|
|
*/
|
|
function resolveRecipientNode(recipientId) {
|
|
if (typeof recipientId !== 'string' || recipientId.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
if (nodesById instanceof Map && nodesById.size > 0) {
|
|
const direct = nodesById.get(recipientId);
|
|
if (direct) {
|
|
return direct;
|
|
}
|
|
}
|
|
|
|
if (nodesByNum instanceof Map && nodesByNum.size > 0) {
|
|
const decimal = Number(recipientId);
|
|
if (Number.isFinite(decimal) && nodesByNum.has(decimal)) {
|
|
return nodesByNum.get(decimal);
|
|
}
|
|
if (/^0x[0-9a-f]+$/i.test(recipientId)) {
|
|
const hexValue = Number.parseInt(recipientId, 16);
|
|
if (Number.isFinite(hexValue) && nodesByNum.has(hexValue)) {
|
|
return nodesByNum.get(hexValue);
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Build a chat log entry for a text message.
|
|
*
|
|
* @param {Object} m Message payload.
|
|
* @returns {HTMLElement} Chat log element.
|
|
*/
|
|
function createMessageChatEntry(m) {
|
|
const div = document.createElement('div');
|
|
const tsSeconds = resolveTimestampSeconds(
|
|
m.rx_time ?? m.rxTime,
|
|
m.rx_iso ?? m.rxIso
|
|
);
|
|
const tsDate = tsSeconds != null ? new Date(tsSeconds * 1000) : null;
|
|
const ts = tsDate ? formatTime(tsDate) : '--:--:--';
|
|
const short = renderShortHtml(m.node?.short_name, m.node?.role, m.node?.long_name, m.node);
|
|
const replyPrefix = resolveReplyPrefix({
|
|
message: m,
|
|
messagesById,
|
|
nodesById,
|
|
renderShortHtml,
|
|
escapeHtml
|
|
});
|
|
|
|
let messageBodyHtml = '';
|
|
if (m && m.encrypted) {
|
|
const notice = formatEncryptedMessageNotice(m);
|
|
if (notice && typeof notice === 'object') {
|
|
const content = notice.content ?? '';
|
|
messageBodyHtml = notice.isHtml ? content : escapeHtml(content);
|
|
} else {
|
|
messageBodyHtml = '';
|
|
}
|
|
} else {
|
|
messageBodyHtml = buildMessageBody({
|
|
message: m || {},
|
|
escapeHtml,
|
|
renderEmojiHtml
|
|
});
|
|
}
|
|
|
|
const combinedSegments = [];
|
|
if (replyPrefix) combinedSegments.push(replyPrefix);
|
|
if (messageBodyHtml) combinedSegments.push(messageBodyHtml);
|
|
const text = combinedSegments.length > 0 ? combinedSegments.join(' ') : '';
|
|
const metadata = extractChatMessageMetadata(m);
|
|
const prefix = formatChatMessagePrefix({
|
|
timestamp: escapeHtml(ts),
|
|
frequency: metadata.frequency ? escapeHtml(metadata.frequency) : ''
|
|
});
|
|
const presetTag = formatChatPresetTag({ presetCode: metadata.presetCode });
|
|
div.className = 'chat-entry-msg';
|
|
div.innerHTML = `${prefix}${presetTag} ${short} ${text}`;
|
|
return div;
|
|
}
|
|
|
|
/**
|
|
* Attach node context to chat log entries when identifier metadata exists.
|
|
*
|
|
* @param {Array<Object>} entries Chat log entries.
|
|
* @returns {Array<Object>} Enriched entries.
|
|
*/
|
|
function attachNodeContextToLogEntries(entries) {
|
|
if (!Array.isArray(entries) || entries.length === 0) {
|
|
return Array.isArray(entries) ? entries : [];
|
|
}
|
|
return entries.map(entry => {
|
|
if (!entry || typeof entry !== 'object') {
|
|
return entry;
|
|
}
|
|
const hasNode = entry.node && typeof entry.node === 'object';
|
|
const hasNeighborNode = entry.neighborNode && typeof entry.neighborNode === 'object';
|
|
const resolvedNode = hasNode ? entry.node : resolveNodeForLogEntryContext(entry);
|
|
const resolvedNeighbor = hasNeighborNode ? entry.neighborNode : resolveNeighborForLogEntry(entry);
|
|
if (resolvedNode === entry.node && resolvedNeighbor === entry.neighborNode) {
|
|
return entry;
|
|
}
|
|
const enriched = { ...entry };
|
|
if (resolvedNode && !hasNode) {
|
|
enriched.node = resolvedNode;
|
|
}
|
|
if (resolvedNeighbor && !hasNeighborNode) {
|
|
enriched.neighborNode = resolvedNeighbor;
|
|
}
|
|
return enriched;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Locate the canonical node associated with a chat log entry for filtering.
|
|
*
|
|
* @param {Object} entry Chat log entry.
|
|
* @returns {?Object} Node payload when available.
|
|
*/
|
|
function resolveNodeForLogEntryContext(entry) {
|
|
if (!entry || typeof entry !== 'object') {
|
|
return null;
|
|
}
|
|
if (nodesById instanceof Map && typeof entry.nodeId === 'string' && nodesById.has(entry.nodeId)) {
|
|
return nodesById.get(entry.nodeId);
|
|
}
|
|
if (nodesByNum instanceof Map && Number.isFinite(entry.nodeNum) && nodesByNum.has(entry.nodeNum)) {
|
|
return nodesByNum.get(entry.nodeNum);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Locate the neighbor node metadata for a chat log entry when available.
|
|
*
|
|
* @param {Object} entry Chat log entry.
|
|
* @returns {?Object} Neighbor node payload when available.
|
|
*/
|
|
function resolveNeighborForLogEntry(entry) {
|
|
if (!entry || typeof entry !== 'object' || !(nodesById instanceof Map)) {
|
|
return null;
|
|
}
|
|
const neighborId = typeof entry.neighborId === 'string' ? entry.neighborId : null;
|
|
if (neighborId && nodesById.has(neighborId)) {
|
|
return nodesById.get(neighborId);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Render the chat history panel with nodes and messages.
|
|
*
|
|
* @param {{
|
|
* nodes?: Array<Object>,
|
|
* messages?: Array<Object>,
|
|
* encryptedMessages?: Array<Object>,
|
|
* telemetryEntries?: Array<Object>,
|
|
* positionEntries?: Array<Object>,
|
|
* neighborEntries?: Array<Object>,
|
|
* traceEntries?: Array<Object>,
|
|
* filterQuery?: string
|
|
* }} params Render inputs.
|
|
* @returns {void}
|
|
*/
|
|
function renderChatLog({
|
|
nodes = [],
|
|
messages = [],
|
|
encryptedMessages = [],
|
|
telemetryEntries = [],
|
|
positionEntries = [],
|
|
neighborEntries = [],
|
|
traceEntries = [],
|
|
filterQuery = ''
|
|
}) {
|
|
if (!CHAT_ENABLED || !chatEl) return;
|
|
const combinedMessages = Array.isArray(messages) ? [...messages] : [];
|
|
if (Array.isArray(encryptedMessages) && encryptedMessages.length > 0) {
|
|
combinedMessages.push(...encryptedMessages);
|
|
}
|
|
messagesById = buildMessageIndex(combinedMessages);
|
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
const { logEntries, channels } = buildChatTabModel({
|
|
nodes,
|
|
telemetry: telemetryEntries,
|
|
positions: positionEntries,
|
|
neighbors: neighborEntries,
|
|
traces: traceEntries,
|
|
messages,
|
|
logOnlyMessages: encryptedMessages,
|
|
nowSeconds,
|
|
windowSeconds: CHAT_RECENT_WINDOW_SECONDS,
|
|
maxChannelIndex: MAX_CHANNEL_INDEX,
|
|
primaryChannelFallbackLabel: config.channel
|
|
});
|
|
|
|
const enrichedLogEntries = attachNodeContextToLogEntries(logEntries);
|
|
const { logEntries: filteredLogEntries, channels: filteredChannels } = filterChatModel(
|
|
{ logEntries: enrichedLogEntries, channels },
|
|
filterQuery
|
|
);
|
|
|
|
const logContent = buildChatFragment({
|
|
entries: filteredLogEntries,
|
|
renderEntry: createChatLogEntry,
|
|
emptyLabel: 'No recent mesh activity.'
|
|
});
|
|
|
|
const channelTabs = filteredChannels.map(channel => ({
|
|
id: channel.id || `channel-${channel.index}`,
|
|
label: channel.label,
|
|
content: buildChatFragment({
|
|
entries: channel.entries.map(e => ({ ts: e.ts, item: e.message })),
|
|
renderEntry: entry => createMessageChatEntry(entry.item),
|
|
emptyLabel: 'No messages on this channel.'
|
|
}),
|
|
index: channel.index,
|
|
isPrimaryFallback: Boolean(channel.isPrimaryFallback)
|
|
}));
|
|
|
|
const tabs = [
|
|
{ id: 'log', label: 'Log', content: logContent },
|
|
...channelTabs
|
|
];
|
|
|
|
const previousActive = chatEl.dataset?.activeTab || null;
|
|
const defaultActive =
|
|
channelTabs.find(tab => tab.isPrimaryFallback)?.id ||
|
|
channelTabs.find(tab => tab.index === 0)?.id ||
|
|
channelTabs[0]?.id ||
|
|
'log';
|
|
renderChatTabs({
|
|
document,
|
|
container: chatEl,
|
|
tabs,
|
|
previousActiveTabId: previousActive,
|
|
defaultActiveTabId: defaultActive
|
|
});
|
|
scrollActiveChatPanelToBottom();
|
|
}
|
|
|
|
/**
|
|
* Construct a document fragment for chat entries, inserting date dividers
|
|
* and optional empty-state labels.
|
|
*
|
|
* @param {{
|
|
* entries: Array<{ ts: number, item: Object }>,
|
|
* renderEntry: Function,
|
|
* emptyLabel?: string
|
|
* }} params Fragment construction parameters.
|
|
* @returns {DocumentFragment} Populated fragment.
|
|
*/
|
|
function buildChatFragment({ entries = [], renderEntry, emptyLabel }) {
|
|
const fragment = document.createDocumentFragment();
|
|
if (!entries || entries.length === 0) {
|
|
if (emptyLabel) {
|
|
const empty = document.createElement('p');
|
|
empty.className = 'chat-empty';
|
|
empty.textContent = emptyLabel;
|
|
fragment.appendChild(empty);
|
|
}
|
|
return fragment;
|
|
}
|
|
const getDivider = createDateDividerFactory();
|
|
const limitedEntries = entries.slice(Math.max(entries.length - CHAT_LIMIT, 0));
|
|
for (const entry of limitedEntries) {
|
|
if (!entry || typeof entry.ts !== 'number') {
|
|
continue;
|
|
}
|
|
const divider = getDivider(entry.ts);
|
|
if (divider) fragment.appendChild(divider);
|
|
if (typeof renderEntry === 'function') {
|
|
const node = renderEntry(entry);
|
|
if (node) {
|
|
fragment.appendChild(node);
|
|
}
|
|
}
|
|
}
|
|
return fragment;
|
|
}
|
|
|
|
/**
|
|
* Pad a numeric value with leading zeros.
|
|
*
|
|
* @param {number} n Numeric value.
|
|
* @returns {string} Padded string.
|
|
*/
|
|
function pad(n) { return String(n).padStart(2, "0"); }
|
|
|
|
/**
|
|
* Format a ``Date`` object as ``HH:MM``.
|
|
*
|
|
* @param {Date} d Date instance.
|
|
* @returns {string} Time string.
|
|
*/
|
|
function formatTime(d) {
|
|
return pad(d.getHours()) + ":" +
|
|
pad(d.getMinutes()) + ":" +
|
|
pad(d.getSeconds());
|
|
}
|
|
|
|
/**
|
|
* Format a ``Date`` object as ``YYYY-MM-DD``.
|
|
*
|
|
* @param {Date} d Date instance.
|
|
* @returns {string} Date string.
|
|
*/
|
|
function formatDate(d) {
|
|
return d.getFullYear() + "-" +
|
|
pad(d.getMonth() + 1) + "-" +
|
|
pad(d.getDate());
|
|
}
|
|
|
|
/**
|
|
* Format hardware model strings for display.
|
|
*
|
|
* @param {*} v Raw hardware model value.
|
|
* @returns {string} Sanitised string.
|
|
*/
|
|
function fmtHw(v) {
|
|
return v && v !== "UNSET" ? String(v) : "";
|
|
}
|
|
|
|
/**
|
|
* Format coordinate values with a configurable precision.
|
|
*
|
|
* @param {*} v Raw coordinate value.
|
|
* @param {number} [d=5] Decimal precision.
|
|
* @returns {string} Formatted coordinate string.
|
|
*/
|
|
function fmtCoords(v, d = 5) {
|
|
if (v == null || v === '') return "";
|
|
const n = Number(v);
|
|
return Number.isFinite(n) ? n.toFixed(d) : "";
|
|
}
|
|
|
|
/**
|
|
* Format SNR readings with a ``dB`` suffix.
|
|
*
|
|
* @param {*} value Raw SNR value.
|
|
* @returns {string} Formatted SNR string.
|
|
*/
|
|
function formatSnrDisplay(value) {
|
|
if (value == null || value === '') return '';
|
|
const n = Number(value);
|
|
if (!Number.isFinite(n)) return '';
|
|
return `${n.toFixed(1)} dB`;
|
|
}
|
|
|
|
/**
|
|
* Normalise node name fields by trimming whitespace.
|
|
*
|
|
* @param {*} value Raw name value.
|
|
* @returns {string} Sanitised name string.
|
|
*/
|
|
function normalizeNodeNameValue(value) {
|
|
if (value == null) return '';
|
|
const str = String(value).trim();
|
|
return str.length ? str : '';
|
|
}
|
|
|
|
/**
|
|
* Compute the node detail path for a given identifier.
|
|
*
|
|
* @param {string|null} identifier Node identifier.
|
|
* @returns {string|null} Detail path.
|
|
*/
|
|
function buildNodeDetailHref(identifier) {
|
|
if (identifier == null) return null;
|
|
const trimmed = String(identifier).trim();
|
|
if (!trimmed) return null;
|
|
const body = trimmed.startsWith('!') ? trimmed.slice(1) : trimmed;
|
|
if (!body) return null;
|
|
const encoded = encodeURIComponent(body);
|
|
return `/nodes/!${encoded}`;
|
|
}
|
|
|
|
/**
|
|
* Ensure ``identifier`` includes the canonical ``!`` prefix.
|
|
*
|
|
* @param {*} identifier Candidate identifier.
|
|
* @returns {string|null} Canonical identifier or ``null``.
|
|
*/
|
|
function canonicalNodeIdentifier(identifier) {
|
|
if (identifier == null) return null;
|
|
const trimmed = String(identifier).trim();
|
|
if (!trimmed) return null;
|
|
return trimmed.startsWith('!') ? trimmed : `!${trimmed}`;
|
|
}
|
|
|
|
/**
|
|
* Render a linked long name pointing to the node detail view.
|
|
*
|
|
* @param {string|null} longName Display name.
|
|
* @param {string|null} identifier Node identifier.
|
|
* @param {string} [className='node-long-link'] Optional class attribute.
|
|
* @returns {string} Escaped HTML snippet.
|
|
*/
|
|
function renderNodeLongNameLink(longName, identifier, className = 'node-long-link') {
|
|
const text = normalizeNodeNameValue(longName);
|
|
if (!text) return '';
|
|
const href = buildNodeDetailHref(identifier);
|
|
if (!href) {
|
|
return escapeHtml(text);
|
|
}
|
|
const classAttr = className ? ` class="${escapeHtml(className)}"` : '';
|
|
const canonicalIdentifier = canonicalNodeIdentifier(identifier);
|
|
const dataAttrs = canonicalIdentifier
|
|
? ` data-node-detail-link="true" data-node-id="${escapeHtml(canonicalIdentifier)}"`
|
|
: ' data-node-detail-link="true"';
|
|
return `<a${classAttr} href="${href}"${dataAttrs}>${escapeHtml(text)}</a>`;
|
|
}
|
|
|
|
/**
|
|
* Determine whether a long name link should trigger the overlay behaviour.
|
|
*
|
|
* @param {?Element} link Anchor element.
|
|
* @returns {boolean} ``true`` when the link participates in overlays.
|
|
*/
|
|
function shouldHandleNodeLongLink(link) {
|
|
if (!link || !link.dataset) return false;
|
|
if ('nodeDetailLink' in link.dataset && link.dataset.nodeDetailLink === 'false') {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Extract the canonical node identifier from the provided link element.
|
|
*
|
|
* @param {?Element} link Anchor element.
|
|
* @returns {string} Canonical node identifier or ``''`` when unavailable.
|
|
*/
|
|
function getNodeIdentifierFromLink(link) {
|
|
if (!link) return '';
|
|
const datasetIdentifier = link.dataset && typeof link.dataset.nodeId === 'string'
|
|
? canonicalNodeIdentifier(link.dataset.nodeId)
|
|
: null;
|
|
if (datasetIdentifier) {
|
|
return datasetIdentifier;
|
|
}
|
|
if (typeof link.getAttribute === 'function') {
|
|
const attrHref = link.getAttribute('href');
|
|
const canonicalFromAttr = extractIdentifierFromHref(attrHref);
|
|
if (canonicalFromAttr) {
|
|
return canonicalFromAttr;
|
|
}
|
|
}
|
|
if (typeof link.href === 'string') {
|
|
const canonicalFromProperty = extractIdentifierFromHref(link.href);
|
|
if (canonicalFromProperty) {
|
|
return canonicalFromProperty;
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Extract the canonical identifier from a node detail hyperlink.
|
|
*
|
|
* @param {string} href Link href attribute.
|
|
* @returns {string} Canonical identifier or ``''``.
|
|
*/
|
|
function extractIdentifierFromHref(href) {
|
|
if (typeof href !== 'string' || href.length === 0) {
|
|
return '';
|
|
}
|
|
const match = href.match(/\/nodes\/(![^/?#]+)/i);
|
|
if (!match || !match[1]) {
|
|
return '';
|
|
}
|
|
try {
|
|
const decoded = decodeURIComponent(match[1]);
|
|
return canonicalNodeIdentifier(decoded) ?? '';
|
|
} catch {
|
|
return canonicalNodeIdentifier(match[1]) ?? '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Determine the preferred display name for overlay content.
|
|
*
|
|
* @param {Object} node Node payload.
|
|
* @returns {string} Friendly display name.
|
|
*/
|
|
function getNodeDisplayNameForOverlay(node) {
|
|
if (!node || typeof node !== 'object') return '';
|
|
return (
|
|
normalizeNodeNameValue(node.long_name ?? node.longName) ||
|
|
normalizeNodeNameValue(node.short_name ?? node.shortName) ||
|
|
(typeof node.node_id === 'string' ? node.node_id : '')
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Populate missing node name fields with sensible defaults.
|
|
*
|
|
* @param {Object} node Node payload.
|
|
* @returns {Object} Updated node reference.
|
|
*/
|
|
function applyNodeNameFallback(node) {
|
|
if (!node || typeof node !== 'object') return;
|
|
const short = normalizeNodeNameValue(node.short_name ?? node.shortName);
|
|
const long = normalizeNodeNameValue(node.long_name ?? node.longName);
|
|
if (short || long) return;
|
|
const nodeId = normalizeNodeNameValue(node.node_id ?? node.nodeId);
|
|
if (!nodeId) return;
|
|
const fallbackShort = nodeId.slice(-4);
|
|
const fallbackLong = `Meshtastic ${nodeId}`;
|
|
node.short_name = fallbackShort;
|
|
node.long_name = fallbackLong;
|
|
if ('shortName' in node) node.shortName = fallbackShort;
|
|
if ('longName' in node) node.longName = fallbackLong;
|
|
}
|
|
|
|
/**
|
|
* Convert a duration in seconds into a human readable string.
|
|
*
|
|
* @param {number} unixSec Duration in seconds.
|
|
* @returns {string} Human readable representation.
|
|
*/
|
|
function timeHum(unixSec) {
|
|
if (!unixSec) return "";
|
|
if (unixSec < 0) return "0s";
|
|
if (unixSec < 60) return `${unixSec}s`;
|
|
if (unixSec < 3600) return `${Math.floor(unixSec/60)}m ${Math.floor((unixSec%60))}s`;
|
|
if (unixSec < 86400) return `${Math.floor(unixSec/3600)}h ${Math.floor((unixSec%3600)/60)}m`;
|
|
return `${Math.floor(unixSec/86400)}d ${Math.floor((unixSec%86400)/3600)}h`;
|
|
}
|
|
|
|
/**
|
|
* Return a relative time string describing how long ago an event occurred.
|
|
*
|
|
* @param {number} unixSec Timestamp in seconds.
|
|
* @param {number} [nowSec] Reference timestamp.
|
|
* @returns {string} Human readable relative time.
|
|
*/
|
|
function timeAgo(unixSec, nowSec = Date.now()/1000) {
|
|
if (!unixSec) return "";
|
|
const diff = Math.floor(nowSec - Number(unixSec));
|
|
if (diff < 0) return "0s";
|
|
if (diff < 60) return `${diff}s`;
|
|
if (diff < 3600) return `${Math.floor(diff/60)}m ${Math.floor((diff%60))}s`;
|
|
if (diff < 86400) return `${Math.floor(diff/3600)}h ${Math.floor((diff%3600)/60)}m`;
|
|
return `${Math.floor(diff/86400)}d ${Math.floor((diff%86400)/3600)}h`;
|
|
}
|
|
|
|
/**
|
|
* Determine how many snapshots should be requested from the API to build a
|
|
* richer aggregate.
|
|
*
|
|
* @param {number} requestedLimit Desired number of unique entities.
|
|
* @param {number} [maxLimit=NODE_LIMIT] Maximum rows accepted by the API.
|
|
* @returns {number} Effective request limit honouring {@link SNAPSHOT_LIMIT}.
|
|
*/
|
|
function resolveSnapshotLimit(requestedLimit, maxLimit = NODE_LIMIT) {
|
|
const base = Number.isFinite(requestedLimit) && requestedLimit > 0
|
|
? Math.floor(requestedLimit)
|
|
: maxLimit;
|
|
const expanded = base * SNAPSHOT_LIMIT;
|
|
const candidate = expanded > base ? expanded : base;
|
|
return Math.min(candidate, maxLimit);
|
|
}
|
|
|
|
/**
|
|
* Fetch the latest nodes from the JSON API.
|
|
*
|
|
* @param {number} [limit=NODE_LIMIT] Maximum number of records.
|
|
* @returns {Promise<Array<Object>>} Parsed node payloads.
|
|
*/
|
|
async function fetchNodes(limit = NODE_LIMIT) {
|
|
const effectiveLimit = resolveSnapshotLimit(limit, NODE_LIMIT);
|
|
const r = await fetch(`/api/nodes?limit=${effectiveLimit}`, { cache: 'no-store' });
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
return r.json();
|
|
}
|
|
|
|
/**
|
|
* Retrieve a single node record by identifier from the API.
|
|
*
|
|
* @param {string} nodeId Canonical node identifier.
|
|
* @returns {Promise<Object|null>} Parsed node payload or null when absent.
|
|
*/
|
|
async function fetchNodeById(nodeId) {
|
|
if (typeof nodeId !== 'string') return null;
|
|
const trimmed = nodeId.trim();
|
|
if (trimmed.length === 0) return null;
|
|
const r = await fetch(`/api/nodes/${encodeURIComponent(trimmed)}`, { cache: 'no-store' });
|
|
if (r.status === 404) return null;
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
return r.json();
|
|
}
|
|
|
|
/**
|
|
* Fetch recent messages from the JSON API.
|
|
*
|
|
* @param {number} [limit=NODE_LIMIT] Maximum number of rows.
|
|
* @param {{ encrypted?: boolean }} [options] Optional retrieval flags.
|
|
* @returns {Promise<Array<Object>>} Parsed message payloads.
|
|
*/
|
|
async function fetchMessages(limit = MESSAGE_LIMIT, options = {}) {
|
|
if (!CHAT_ENABLED) return [];
|
|
const safeLimit = normaliseMessageLimit(limit);
|
|
const params = new URLSearchParams({ limit: String(safeLimit) });
|
|
if (options && options.encrypted) {
|
|
params.set('encrypted', 'true');
|
|
}
|
|
const query = params.toString();
|
|
const r = await fetch(`/api/messages?${query}`, { cache: 'no-store' });
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
return r.json();
|
|
}
|
|
|
|
/**
|
|
* Fetch neighbour information from the JSON API.
|
|
*
|
|
* @param {number} [limit=NODE_LIMIT] Maximum number of rows.
|
|
* @returns {Promise<Array<Object>>} Parsed neighbour payloads.
|
|
*/
|
|
async function fetchNeighbors(limit = NODE_LIMIT) {
|
|
const effectiveLimit = resolveSnapshotLimit(limit, NODE_LIMIT);
|
|
const r = await fetch(`/api/neighbors?limit=${effectiveLimit}`, { cache: 'no-store' });
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
return r.json();
|
|
}
|
|
|
|
/**
|
|
* Fetch traceroute observations from the JSON API.
|
|
*
|
|
* @param {number} [limit=TRACE_LIMIT] Maximum number of records.
|
|
* @returns {Promise<Array<Object>>} Parsed trace payloads.
|
|
*/
|
|
async function fetchTraces(limit = TRACE_LIMIT) {
|
|
const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : TRACE_LIMIT;
|
|
const effectiveLimit = Math.min(safeLimit, NODE_LIMIT);
|
|
const r = await fetch(`/api/traces?limit=${effectiveLimit}`, { cache: 'no-store' });
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
const traces = await r.json();
|
|
return filterRecentTraces(traces, TRACE_MAX_AGE_SECONDS);
|
|
}
|
|
|
|
/**
|
|
* Fetch telemetry entries from the JSON API.
|
|
*
|
|
* @param {number} [limit=NODE_LIMIT] Maximum number of rows.
|
|
* @returns {Promise<Array<Object>>} Parsed telemetry payloads.
|
|
*/
|
|
async function fetchTelemetry(limit = NODE_LIMIT) {
|
|
const effectiveLimit = resolveSnapshotLimit(limit, NODE_LIMIT);
|
|
const r = await fetch(`/api/telemetry?limit=${effectiveLimit}`, { cache: 'no-store' });
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
return r.json();
|
|
}
|
|
|
|
/**
|
|
* Fetch position packets from the JSON API.
|
|
*
|
|
* @param {number} [limit=NODE_LIMIT] Maximum number of rows.
|
|
* @returns {Promise<Array<Object>>} Parsed position payloads.
|
|
*/
|
|
async function fetchPositions(limit = NODE_LIMIT) {
|
|
const effectiveLimit = resolveSnapshotLimit(limit, NODE_LIMIT);
|
|
const r = await fetch(`/api/positions?limit=${effectiveLimit}`, { cache: 'no-store' });
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
return r.json();
|
|
}
|
|
|
|
/**
|
|
* Convert arbitrary values to finite numbers when possible.
|
|
*
|
|
* @param {*} value Raw value.
|
|
* @returns {number|null} Finite number or null when conversion fails.
|
|
*/
|
|
function toFiniteNumber(value) {
|
|
if (value == null || value === '') return null;
|
|
const num = typeof value === 'number' ? value : Number(value);
|
|
return Number.isFinite(num) ? num : null;
|
|
}
|
|
|
|
/**
|
|
* Determine the best-effort timestamp in seconds from numeric or ISO values.
|
|
*
|
|
* @param {*} numeric Numeric timestamp.
|
|
* @param {*} isoString ISO formatted timestamp.
|
|
* @returns {number|null} Timestamp in seconds.
|
|
*/
|
|
function resolveTimestampSeconds(numeric, isoString) {
|
|
const parsedNumeric = toFiniteNumber(numeric);
|
|
if (parsedNumeric != null) return parsedNumeric;
|
|
if (typeof isoString === 'string' && isoString.length) {
|
|
const parsedIso = Date.parse(isoString);
|
|
if (Number.isFinite(parsedIso)) {
|
|
return parsedIso / 1000;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Filter trace entries to discard packets older than the configured window.
|
|
*
|
|
* @param {Array<Object>} traces Trace payloads.
|
|
* @param {number} [maxAgeSeconds=TRACE_MAX_AGE_SECONDS] Maximum allowed age in seconds.
|
|
* @returns {Array<Object>} Recent trace entries.
|
|
*/
|
|
function filterRecentTraces(traces, maxAgeSeconds = TRACE_MAX_AGE_SECONDS) {
|
|
if (!Array.isArray(traces)) {
|
|
return [];
|
|
}
|
|
if (!Number.isFinite(maxAgeSeconds) || maxAgeSeconds <= 0) {
|
|
return [...traces];
|
|
}
|
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
const cutoff = nowSeconds - maxAgeSeconds;
|
|
return traces.filter(trace => {
|
|
const rxTime = resolveTimestampSeconds(trace?.rx_time ?? trace?.rxTime, trace?.rx_iso ?? trace?.rxIso);
|
|
return rxTime != null && rxTime >= cutoff;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Merge recent position packets into the node list.
|
|
*
|
|
* @param {Array<Object>} nodes Node payloads.
|
|
* @param {Array<Object>} positions Position entries.
|
|
* @returns {Array<Object>} Updated node collection.
|
|
*/
|
|
function mergePositionsIntoNodes(nodes, positions) {
|
|
if (!Array.isArray(nodes) || !Array.isArray(positions) || nodes.length === 0) return;
|
|
|
|
const nodesById = new Map();
|
|
for (const node of nodes) {
|
|
if (!node || typeof node !== 'object') continue;
|
|
const key = typeof node.node_id === 'string' ? node.node_id : null;
|
|
if (key) nodesById.set(key, node);
|
|
}
|
|
|
|
if (nodesById.size === 0) return;
|
|
|
|
const updated = new Set();
|
|
for (const pos of positions) {
|
|
if (!pos || typeof pos !== 'object') continue;
|
|
const nodeId = typeof pos.node_id === 'string' ? pos.node_id : null;
|
|
if (!nodeId || updated.has(nodeId)) continue;
|
|
const node = nodesById.get(nodeId);
|
|
if (!node) continue;
|
|
|
|
const lat = toFiniteNumber(pos.latitude);
|
|
const lon = toFiniteNumber(pos.longitude);
|
|
if (lat == null || lon == null) continue;
|
|
|
|
const currentTimestamp = resolveTimestampSeconds(node.position_time, node.pos_time_iso);
|
|
const incomingTimestamp = resolveTimestampSeconds(pos.position_time, pos.position_time_iso);
|
|
if (currentTimestamp != null) {
|
|
if (incomingTimestamp == null || incomingTimestamp <= currentTimestamp) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
updated.add(nodeId);
|
|
node.latitude = lat;
|
|
node.longitude = lon;
|
|
|
|
const alt = toFiniteNumber(pos.altitude);
|
|
if (alt != null) node.altitude = alt;
|
|
|
|
const posTime = toFiniteNumber(pos.position_time);
|
|
if (posTime != null) {
|
|
node.position_time = posTime;
|
|
node.pos_time_iso = typeof pos.position_time_iso === 'string' && pos.position_time_iso.length
|
|
? pos.position_time_iso
|
|
: new Date(posTime * 1000).toISOString();
|
|
} else if (typeof pos.position_time_iso === 'string' && pos.position_time_iso.length) {
|
|
node.pos_time_iso = pos.position_time_iso;
|
|
}
|
|
|
|
if (pos.location_source != null && pos.location_source !== '') {
|
|
node.location_source = pos.location_source;
|
|
}
|
|
|
|
const precision = toFiniteNumber(pos.precision_bits);
|
|
if (precision != null) node.precision_bits = precision;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build a lookup table of telemetry entries keyed by node identifier.
|
|
*
|
|
* @param {Array<Object>} entries Telemetry payloads.
|
|
* @returns {Map<string, Object>} Indexed telemetry data.
|
|
*/
|
|
function buildTelemetryIndex(entries) {
|
|
const byNodeId = new Map();
|
|
const byNodeNum = new Map();
|
|
if (!Array.isArray(entries)) {
|
|
return { byNodeId, byNodeNum };
|
|
}
|
|
for (const entry of entries) {
|
|
if (!entry || typeof entry !== 'object') continue;
|
|
const nodeId = typeof entry.node_id === 'string' ? entry.node_id : (typeof entry.nodeId === 'string' ? entry.nodeId : null);
|
|
const nodeNumRaw = entry.node_num ?? entry.nodeNum;
|
|
const nodeNum = typeof nodeNumRaw === 'number' ? nodeNumRaw : Number(nodeNumRaw);
|
|
const rxTime = toFiniteNumber(entry.rx_time ?? entry.rxTime);
|
|
const telemetryTime = toFiniteNumber(entry.telemetry_time ?? entry.telemetryTime);
|
|
const timestamp = rxTime != null ? rxTime : telemetryTime != null ? telemetryTime : Number.NEGATIVE_INFINITY;
|
|
if (nodeId) {
|
|
const existing = byNodeId.get(nodeId);
|
|
if (!existing || timestamp > existing.timestamp) {
|
|
byNodeId.set(nodeId, { entry, timestamp });
|
|
}
|
|
}
|
|
if (Number.isFinite(nodeNum)) {
|
|
const existing = byNodeNum.get(nodeNum);
|
|
if (!existing || timestamp > existing.timestamp) {
|
|
byNodeNum.set(nodeNum, { entry, timestamp });
|
|
}
|
|
}
|
|
}
|
|
return { byNodeId, byNodeNum };
|
|
}
|
|
|
|
/**
|
|
* Merge telemetry metrics into the node list.
|
|
*
|
|
* @param {Array<Object>} nodes Node payloads.
|
|
* @param {Array<Object>} telemetryEntries Telemetry data.
|
|
* @returns {Array<Object>} Updated node collection.
|
|
*/
|
|
function mergeTelemetryIntoNodes(nodes, telemetryEntries) {
|
|
if (!Array.isArray(nodes) || !nodes.length) return;
|
|
const { byNodeId, byNodeNum } = buildTelemetryIndex(telemetryEntries);
|
|
for (const node of nodes) {
|
|
if (!node || typeof node !== 'object') continue;
|
|
const nodeId = typeof node.node_id === 'string' ? node.node_id : (typeof node.nodeId === 'string' ? node.nodeId : null);
|
|
const nodeNumRaw = node.num ?? node.node_num ?? node.nodeNum;
|
|
const nodeNum = typeof nodeNumRaw === 'number' ? nodeNumRaw : Number(nodeNumRaw);
|
|
let telemetryEntry = null;
|
|
if (nodeId && byNodeId.has(nodeId)) {
|
|
telemetryEntry = byNodeId.get(nodeId).entry;
|
|
} else if (Number.isFinite(nodeNum) && byNodeNum.has(nodeNum)) {
|
|
telemetryEntry = byNodeNum.get(nodeNum).entry;
|
|
}
|
|
if (!telemetryEntry || typeof telemetryEntry !== 'object') continue;
|
|
const metrics = {
|
|
battery_level: toFiniteNumber(telemetryEntry.battery_level ?? telemetryEntry.batteryLevel),
|
|
voltage: toFiniteNumber(telemetryEntry.voltage),
|
|
uptime_seconds: toFiniteNumber(telemetryEntry.uptime_seconds ?? telemetryEntry.uptimeSeconds),
|
|
channel_utilization: toFiniteNumber(telemetryEntry.channel_utilization ?? telemetryEntry.channelUtilization),
|
|
air_util_tx: toFiniteNumber(telemetryEntry.air_util_tx ?? telemetryEntry.airUtilTx),
|
|
temperature: toFiniteNumber(telemetryEntry.temperature),
|
|
relative_humidity: toFiniteNumber(telemetryEntry.relative_humidity ?? telemetryEntry.relativeHumidity),
|
|
barometric_pressure: toFiniteNumber(telemetryEntry.barometric_pressure ?? telemetryEntry.barometricPressure),
|
|
};
|
|
for (const [key, value] of Object.entries(metrics)) {
|
|
if (value == null) continue;
|
|
node[key] = value;
|
|
}
|
|
const telemetryTime = toFiniteNumber(telemetryEntry.telemetry_time ?? telemetryEntry.telemetryTime);
|
|
if (telemetryTime != null) {
|
|
node.telemetry_time = telemetryTime;
|
|
}
|
|
const rxTime = toFiniteNumber(telemetryEntry.rx_time ?? telemetryEntry.rxTime);
|
|
if (rxTime != null) {
|
|
node.telemetry_rx_time = rxTime;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compute distance from the configured map center.
|
|
*
|
|
* @param {number} lat Latitude in degrees.
|
|
* @param {number} lon Longitude in degrees.
|
|
* @returns {number|null} Distance in kilometres.
|
|
*/
|
|
function distanceFromCenterKm(lat, lon) {
|
|
if (hasLeaflet && mapCenterLatLng && typeof mapCenterLatLng.distanceTo === 'function') {
|
|
try {
|
|
return L.latLng(lat, lon).distanceTo(mapCenterLatLng) / 1000;
|
|
} catch (err) {
|
|
// fall through to haversine fallback
|
|
}
|
|
}
|
|
return haversineDistanceKm(lat, lon, MAP_CENTER_COORDS.lat, MAP_CENTER_COORDS.lon);
|
|
}
|
|
|
|
/**
|
|
* Annotate nodes with their distance from the map center.
|
|
*
|
|
* @param {Array<Object>} nodes Node payloads.
|
|
* @returns {Array<Object>} Updated node collection.
|
|
*/
|
|
function computeDistances(nodes) {
|
|
for (const n of nodes) {
|
|
const latRaw = n.latitude;
|
|
const lonRaw = n.longitude;
|
|
if (latRaw == null || latRaw === '' || lonRaw == null || lonRaw === '') {
|
|
n.distance_km = null;
|
|
continue;
|
|
}
|
|
const lat = Number(latRaw);
|
|
const lon = Number(lonRaw);
|
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
|
n.distance_km = null;
|
|
continue;
|
|
}
|
|
n.distance_km = distanceFromCenterKm(lat, lon);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render the nodes table with sorted and filtered data.
|
|
*
|
|
* @param {Array<Object>} nodes Node payloads.
|
|
* @param {number} nowSec Reference timestamp.
|
|
* @returns {void}
|
|
*/
|
|
function renderTable(nodes, nowSec) {
|
|
const tb = document.querySelector('#nodes tbody');
|
|
if (!tb) {
|
|
overlayStack.cleanupOrphans();
|
|
return;
|
|
}
|
|
const frag = document.createDocumentFragment();
|
|
for (const n of nodes) {
|
|
const tr = document.createElement('tr');
|
|
const lastPositionTime = toFiniteNumber(n.position_time ?? n.positionTime);
|
|
const lastPositionCell = lastPositionTime != null ? timeAgo(lastPositionTime, nowSec) : '';
|
|
const latitudeDisplay = fmtCoords(n.latitude);
|
|
const longitudeDisplay = fmtCoords(n.longitude);
|
|
const nodeDisplayName = getNodeDisplayNameForOverlay(n);
|
|
const modemMetadata = extractModemMetadata(n);
|
|
const loraFrequencyText = formatLoraFrequencyMHz(modemMetadata.loraFreq);
|
|
const loraFrequencyDisplay = loraFrequencyText ? escapeHtml(loraFrequencyText) : '';
|
|
const modemPresetDisplay = modemMetadata.modemPreset ? escapeHtml(modemMetadata.modemPreset) : '';
|
|
const longNameHtml = renderNodeLongNameLink(n.long_name, n.node_id);
|
|
tr.innerHTML = `
|
|
<td class="mono nodes-col nodes-col--node-id">${n.node_id || ""}</td>
|
|
<td class="nodes-col nodes-col--short-name">${renderShortHtml(n.short_name, n.role, n.long_name, n)}</td>
|
|
<td class="nodes-col nodes-col--long-name">${longNameHtml}</td>
|
|
<td class="nodes-col nodes-col--frequency">${loraFrequencyDisplay}</td>
|
|
<td class="nodes-col nodes-col--modem-preset">${modemPresetDisplay}</td>
|
|
<td class="nodes-col nodes-col--last-seen">${timeAgo(n.last_heard, nowSec)}</td>
|
|
<td class="nodes-col nodes-col--role">${n.role || "CLIENT"}</td>
|
|
<td class="nodes-col nodes-col--hw-model">${fmtHw(n.hw_model)}</td>
|
|
<td class="nodes-col nodes-col--battery">${fmtAlt(n.battery_level, "%")}</td>
|
|
<td class="nodes-col nodes-col--voltage">${fmtAlt(n.voltage, "V")}</td>
|
|
<td class="nodes-col nodes-col--uptime">${timeHum(n.uptime_seconds)}</td>
|
|
<td class="nodes-col nodes-col--channel-util">${fmtTx(n.channel_utilization)}</td>
|
|
<td class="nodes-col nodes-col--air-util-tx">${fmtTx(n.air_util_tx)}</td>
|
|
<td class="nodes-col nodes-col--temperature">${fmtTemperature(n.temperature)}</td>
|
|
<td class="nodes-col nodes-col--humidity">${fmtHumidity(n.relative_humidity)}</td>
|
|
<td class="nodes-col nodes-col--pressure">${fmtPressure(n.barometric_pressure)}</td>
|
|
<td class="nodes-col nodes-col--latitude">${latitudeDisplay}</td>
|
|
<td class="nodes-col nodes-col--longitude">${longitudeDisplay}</td>
|
|
<td class="nodes-col nodes-col--altitude">${fmtAlt(n.altitude, "m")}</td>
|
|
<td class="mono nodes-col nodes-col--last-position">${lastPositionCell}</td>`;
|
|
|
|
enhanceCoordinateCell({
|
|
cell: tr.querySelector('.nodes-col--latitude'),
|
|
document,
|
|
displayText: latitudeDisplay,
|
|
formattedLatitude: latitudeDisplay,
|
|
formattedLongitude: longitudeDisplay,
|
|
lat: n.latitude,
|
|
lon: n.longitude,
|
|
nodeName: nodeDisplayName,
|
|
onActivate: focusMapOnCoordinates
|
|
});
|
|
enhanceCoordinateCell({
|
|
cell: tr.querySelector('.nodes-col--longitude'),
|
|
document,
|
|
displayText: longitudeDisplay,
|
|
formattedLatitude: latitudeDisplay,
|
|
formattedLongitude: longitudeDisplay,
|
|
lat: n.latitude,
|
|
lon: n.longitude,
|
|
nodeName: nodeDisplayName,
|
|
onActivate: focusMapOnCoordinates
|
|
});
|
|
frag.appendChild(tr);
|
|
}
|
|
tb.replaceChildren(frag);
|
|
overlayStack.cleanupOrphans();
|
|
}
|
|
|
|
/**
|
|
* Render the Leaflet map markers and neighbour connections.
|
|
*
|
|
* @param {Array<Object>} nodes Node payloads.
|
|
* @param {number} nowSec Reference timestamp.
|
|
* @returns {void}
|
|
*/
|
|
function renderMap(nodes, nowSec) {
|
|
if (!map || !markersLayer || !hasLeaflet) {
|
|
return;
|
|
}
|
|
if (neighborLinesLayer) {
|
|
neighborLinesLayer.clearLayers();
|
|
}
|
|
if (traceLinesLayer) {
|
|
traceLinesLayer.clearLayers();
|
|
}
|
|
markersLayer.clearLayers();
|
|
const pts = [];
|
|
const nodesById = new Map();
|
|
for (const node of nodes) {
|
|
if (!node || typeof node !== 'object') continue;
|
|
const nodeId = node.node_id;
|
|
if (typeof nodeId !== 'string' || nodeId.length === 0) continue;
|
|
nodesById.set(nodeId, node);
|
|
}
|
|
const traceSegments = traceLinesLayer
|
|
? buildTraceSegments(allTraces, nodes, {
|
|
limitDistance: LIMIT_DISTANCE,
|
|
maxDistanceKm: MAX_DISTANCE_KM,
|
|
colorForNode: node => getRoleColor(node.role)
|
|
})
|
|
: [];
|
|
|
|
if (neighborLinesLayer && Array.isArray(allNeighbors) && allNeighbors.length) {
|
|
const neighborSegments = [];
|
|
const seenDirections = new Set();
|
|
for (const entry of allNeighbors) {
|
|
if (!entry || typeof entry !== 'object') continue;
|
|
const sourceId = typeof entry.node_id === 'string' ? entry.node_id : null;
|
|
const targetId = typeof entry.neighbor_id === 'string' ? entry.neighbor_id : null;
|
|
if (!sourceId || !targetId) continue;
|
|
const directionKey = `${sourceId}→${targetId}`;
|
|
if (seenDirections.has(directionKey)) continue;
|
|
seenDirections.add(directionKey);
|
|
|
|
const sourceNode = nodesById.get(sourceId);
|
|
const targetNode = nodesById.get(targetId);
|
|
if (!sourceNode || !targetNode) continue;
|
|
|
|
const srcLatRaw = sourceNode.latitude;
|
|
const srcLonRaw = sourceNode.longitude;
|
|
const tgtLatRaw = targetNode.latitude;
|
|
const tgtLonRaw = targetNode.longitude;
|
|
if (
|
|
srcLatRaw == null || srcLatRaw === '' || srcLonRaw == null || srcLonRaw === '' ||
|
|
tgtLatRaw == null || tgtLatRaw === '' || tgtLonRaw == null || tgtLonRaw === ''
|
|
) {
|
|
continue;
|
|
}
|
|
const srcLat = Number(srcLatRaw);
|
|
const srcLon = Number(srcLonRaw);
|
|
const tgtLat = Number(tgtLatRaw);
|
|
const tgtLon = Number(tgtLonRaw);
|
|
if (!Number.isFinite(srcLat) || !Number.isFinite(srcLon) || !Number.isFinite(tgtLat) || !Number.isFinite(tgtLon)) {
|
|
continue;
|
|
}
|
|
if (LIMIT_DISTANCE && sourceNode.distance_km != null && sourceNode.distance_km > MAX_DISTANCE_KM) continue;
|
|
if (LIMIT_DISTANCE && targetNode.distance_km != null && targetNode.distance_km > MAX_DISTANCE_KM) continue;
|
|
|
|
const priority = getRoleRenderPriority(sourceNode.role);
|
|
const rxTimeRaw = entry.rx_time;
|
|
let rxTime = 0;
|
|
if (typeof rxTimeRaw === 'number' && Number.isFinite(rxTimeRaw)) {
|
|
rxTime = rxTimeRaw;
|
|
} else if (typeof rxTimeRaw === 'string') {
|
|
const parsed = Number(rxTimeRaw);
|
|
rxTime = Number.isFinite(parsed) ? parsed : 0;
|
|
}
|
|
|
|
const snrValue = toFiniteNumber(entry.snr);
|
|
const sourceDisplayName = getNodeDisplayNameForOverlay(sourceNode);
|
|
const targetDisplayName = getNodeDisplayNameForOverlay(targetNode);
|
|
const sourceShortName = normalizeNodeNameValue(sourceNode.short_name ?? sourceNode.shortName);
|
|
const targetShortName = normalizeNodeNameValue(targetNode.short_name ?? targetNode.shortName);
|
|
|
|
neighborSegments.push({
|
|
latlngs: [[srcLat, srcLon], [tgtLat, tgtLon]],
|
|
color: getRoleColor(sourceNode.role),
|
|
priority,
|
|
rxTime,
|
|
sourceId,
|
|
targetId,
|
|
snr: snrValue,
|
|
sourceDisplayName,
|
|
targetDisplayName,
|
|
sourceShortName,
|
|
sourceRole: sourceNode.role,
|
|
targetShortName,
|
|
targetRole: targetNode.role
|
|
});
|
|
}
|
|
|
|
neighborSegments
|
|
.sort((a, b) => {
|
|
if (a.priority !== b.priority) return a.priority - b.priority;
|
|
if (a.rxTime !== b.rxTime) return b.rxTime - a.rxTime;
|
|
if (a.sourceId !== b.sourceId) return a.sourceId < b.sourceId ? -1 : 1;
|
|
if (a.targetId !== b.targetId) return a.targetId < b.targetId ? -1 : 1;
|
|
return 0;
|
|
})
|
|
.forEach(segment => {
|
|
const polyline = L.polyline(segment.latlngs, {
|
|
color: segment.color,
|
|
weight: 2,
|
|
opacity: 0.42,
|
|
className: 'neighbor-connection-line'
|
|
}).addTo(neighborLinesLayer);
|
|
if (polyline && typeof polyline.bindTooltip === 'function') {
|
|
const tooltipHtml = buildNeighborTooltipHtml({
|
|
...segment,
|
|
sourceNode: nodesById.get(segment.sourceId),
|
|
targetNode: nodesById.get(segment.targetId)
|
|
});
|
|
if (tooltipHtml) {
|
|
polyline.bindTooltip(tooltipHtml, {
|
|
direction: 'center',
|
|
opacity: 0.92,
|
|
sticky: true,
|
|
className: 'trace-tooltip'
|
|
});
|
|
}
|
|
}
|
|
if (polyline && typeof polyline.on === 'function') {
|
|
polyline.on('click', event => {
|
|
if (event && event.originalEvent) {
|
|
if (typeof event.originalEvent.preventDefault === 'function') {
|
|
event.originalEvent.preventDefault();
|
|
}
|
|
if (typeof event.originalEvent.stopPropagation === 'function') {
|
|
event.originalEvent.stopPropagation();
|
|
}
|
|
}
|
|
const clickTarget =
|
|
event.originalEvent &&
|
|
typeof Element !== 'undefined' &&
|
|
event.originalEvent.target instanceof Element
|
|
? event.originalEvent.target
|
|
: null;
|
|
const anchorEl = polyline.getElement() || clickTarget;
|
|
if (polyline && typeof polyline.isTooltipOpen === 'function' && typeof polyline.openTooltip === 'function') {
|
|
if (polyline.isTooltipOpen()) {
|
|
polyline.closeTooltip();
|
|
} else {
|
|
polyline.openTooltip();
|
|
}
|
|
}
|
|
if (!anchorEl) return;
|
|
if (overlayStack.isOpen(anchorEl)) {
|
|
overlayStack.close(anchorEl);
|
|
return;
|
|
}
|
|
openNeighborOverlay(anchorEl, segment);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
if (traceLinesLayer && traceSegments.length) {
|
|
traceSegments
|
|
.sort((a, b) => {
|
|
const rxA = Number.isFinite(a.rxTime) ? a.rxTime : -Infinity;
|
|
const rxB = Number.isFinite(b.rxTime) ? b.rxTime : -Infinity;
|
|
if (rxA === rxB) return 0;
|
|
return rxA - rxB;
|
|
})
|
|
.forEach(segment => {
|
|
const polyline = L.polyline(segment.latlngs, {
|
|
color: segment.color,
|
|
weight: 2,
|
|
opacity: 0.42,
|
|
dashArray: '6 6',
|
|
className: 'neighbor-connection-line trace-connection-line'
|
|
}).addTo(traceLinesLayer);
|
|
if (polyline && typeof polyline.bindTooltip === 'function') {
|
|
const tooltipHtml = buildTraceTooltipHtml(segment.pathNodes);
|
|
if (tooltipHtml) {
|
|
polyline.bindTooltip(tooltipHtml, {
|
|
direction: 'center',
|
|
opacity: 0.92,
|
|
sticky: true,
|
|
className: 'trace-tooltip'
|
|
});
|
|
}
|
|
}
|
|
if (polyline && typeof polyline.on === 'function') {
|
|
polyline.on('click', event => {
|
|
if (event && event.originalEvent) {
|
|
if (typeof event.originalEvent.preventDefault === 'function') {
|
|
event.originalEvent.preventDefault();
|
|
}
|
|
if (typeof event.originalEvent.stopPropagation === 'function') {
|
|
event.originalEvent.stopPropagation();
|
|
}
|
|
}
|
|
if (polyline && typeof polyline.isTooltipOpen === 'function' && typeof polyline.openTooltip === 'function') {
|
|
if (polyline.isTooltipOpen()) {
|
|
polyline.closeTooltip();
|
|
} else {
|
|
polyline.openTooltip();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
const nodesByRenderOrder = nodes
|
|
.map((node, index) => ({ node, index }))
|
|
.sort((a, b) => {
|
|
const orderA = getRoleRenderPriority(a.node && a.node.role);
|
|
const orderB = getRoleRenderPriority(b.node && b.node.role);
|
|
if (orderA !== orderB) return orderA - orderB;
|
|
return a.index - b.index;
|
|
})
|
|
.map(entry => entry.node);
|
|
|
|
for (const n of nodesByRenderOrder) {
|
|
const latRaw = n.latitude, lonRaw = n.longitude;
|
|
if (latRaw == null || latRaw === '' || lonRaw == null || lonRaw === '') continue;
|
|
const lat = Number(latRaw), lon = Number(lonRaw);
|
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
|
if (LIMIT_DISTANCE && n.distance_km != null && n.distance_km > MAX_DISTANCE_KM) continue;
|
|
|
|
const color = getRoleColor(n.role);
|
|
const marker = L.circleMarker([lat, lon], {
|
|
radius: 9,
|
|
color: '#000',
|
|
weight: 1,
|
|
fillColor: color,
|
|
fillOpacity: 0.7,
|
|
opacity: 0.7
|
|
});
|
|
|
|
const fallbackOverlayProvider = () => mergeOverlayDetails(null, n);
|
|
let markerToken = 0;
|
|
marker.addTo(markersLayer);
|
|
pts.push([lat, lon]);
|
|
|
|
attachNodeInfoRefreshToMarker({
|
|
marker,
|
|
getOverlayFallback: fallbackOverlayProvider,
|
|
refreshNodeInformation,
|
|
mergeOverlayDetails,
|
|
createRequestToken: anchor => {
|
|
if (anchor) {
|
|
return overlayStack.incrementRequestToken(anchor);
|
|
}
|
|
markerToken += 1;
|
|
return markerToken;
|
|
},
|
|
isTokenCurrent: (anchor, token) => {
|
|
if (anchor) {
|
|
return overlayStack.isTokenCurrent(anchor, token);
|
|
}
|
|
return token === markerToken;
|
|
},
|
|
showLoading: (anchor, info) => {
|
|
if (anchor) {
|
|
showShortInfoLoading(anchor, info);
|
|
}
|
|
},
|
|
showDetails: (anchor, info) => {
|
|
if (anchor) {
|
|
closeUnrelatedShortOverlays(anchor);
|
|
openShortInfoOverlay(anchor, info);
|
|
}
|
|
},
|
|
showError: (anchor, info, error) => {
|
|
console.warn('Failed to refresh node information for map marker', error);
|
|
if (anchor) {
|
|
closeUnrelatedShortOverlays(anchor);
|
|
openShortInfoOverlay(anchor, info);
|
|
}
|
|
},
|
|
shouldHandleClick: anchor => {
|
|
if (!anchor) return true;
|
|
if (overlayStack.isOpen(anchor)) {
|
|
overlayStack.close(anchor);
|
|
return false;
|
|
}
|
|
return true;
|
|
},
|
|
});
|
|
}
|
|
if (pts.length && fitBoundsEl && fitBoundsEl.checked) {
|
|
const bounds = computeBoundsForPoints(pts, { ...autoFitBoundsConfig });
|
|
fitMapToBounds(bounds, { animate: false, paddingPx: AUTO_FIT_PADDING_PX });
|
|
}
|
|
overlayStack.cleanupOrphans();
|
|
}
|
|
|
|
/**
|
|
* Test whether a node matches the free-text filter string.
|
|
*
|
|
* @param {Object} node Node payload.
|
|
* @param {string} query Filter query.
|
|
* @returns {boolean} True when the node should be visible.
|
|
*/
|
|
function matchesTextFilter(node, query) {
|
|
if (!query) return true;
|
|
return [node?.node_id, node?.short_name, node?.long_name]
|
|
.filter(value => value != null && value !== '')
|
|
.some(value => String(value).toLowerCase().includes(query));
|
|
}
|
|
|
|
/**
|
|
* Test whether a node matches the active role filters.
|
|
*
|
|
* @param {Object} node Node payload.
|
|
* @returns {boolean} True when the node should be visible.
|
|
*/
|
|
function matchesRoleFilter(node) {
|
|
if (!activeRoleFilters.size) return true;
|
|
const roleKey = getRoleKey(node && node.role);
|
|
return activeRoleFilters.has(roleKey);
|
|
}
|
|
|
|
/**
|
|
* Show or hide the filter clear button depending on the input state.
|
|
*
|
|
* @returns {void}
|
|
*/
|
|
function updateFilterClearVisibility() {
|
|
if (!filterInput || !filterClearButton) return;
|
|
const hasValue = filterInput.value && filterInput.value.length > 0;
|
|
filterClearButton.hidden = !hasValue;
|
|
}
|
|
|
|
/**
|
|
* Apply text and role filters to the node list and re-render outputs.
|
|
*
|
|
* @returns {void}
|
|
*/
|
|
function applyFilter() {
|
|
updateFilterClearVisibility();
|
|
const filterQuery = filterInput ? filterInput.value : '';
|
|
const q = normaliseChatFilterQuery(filterQuery);
|
|
const filteredNodes = allNodes.filter(n => matchesTextFilter(n, q) && matchesRoleFilter(n));
|
|
const sortedNodes = sortNodes(filteredNodes);
|
|
const nowSec = Date.now()/1000;
|
|
renderTable(sortedNodes, nowSec);
|
|
renderMap(sortedNodes, nowSec);
|
|
updateCount(sortedNodes, nowSec);
|
|
updateRefreshInfo(sortedNodes, nowSec);
|
|
updateSortIndicators();
|
|
renderChatLog({
|
|
nodes: allNodes,
|
|
messages: allMessages,
|
|
encryptedMessages: allEncryptedMessages,
|
|
telemetryEntries: allTelemetryEntries,
|
|
positionEntries: allPositionEntries,
|
|
neighborEntries: allNeighbors,
|
|
traceEntries: allTraces,
|
|
filterQuery
|
|
});
|
|
}
|
|
|
|
if (filterInput) {
|
|
filterInput.addEventListener('input', () => {
|
|
updateFilterClearVisibility();
|
|
applyFilter();
|
|
});
|
|
updateFilterClearVisibility();
|
|
}
|
|
|
|
if (filterClearButton) {
|
|
filterClearButton.addEventListener('click', () => {
|
|
if (!filterInput) return;
|
|
if (filterInput.value.length === 0) {
|
|
filterInput.focus();
|
|
return;
|
|
}
|
|
filterInput.value = '';
|
|
updateFilterClearVisibility();
|
|
applyFilter();
|
|
filterInput.focus();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Refresh nodes, messages, and telemetry from the API and update the UI.
|
|
*
|
|
* @returns {Promise<void>} Resolves when rendering completes.
|
|
*/
|
|
async function refresh() {
|
|
try {
|
|
if (statusEl) {
|
|
statusEl.textContent = 'refreshing…';
|
|
}
|
|
const neighborPromise = fetchNeighbors().catch(err => {
|
|
console.warn('neighbor refresh failed; continuing without connections', err);
|
|
return [];
|
|
});
|
|
const telemetryPromise = fetchTelemetry().catch(err => {
|
|
console.warn('telemetry refresh failed; continuing without telemetry', err);
|
|
return [];
|
|
});
|
|
const positionsPromise = fetchPositions().catch(err => {
|
|
console.warn('position refresh failed; continuing without updates', err);
|
|
return [];
|
|
});
|
|
const tracesPromise = fetchTraces().catch(err => {
|
|
console.warn('trace refresh failed; continuing without traceroutes', err);
|
|
return [];
|
|
});
|
|
const encryptedMessagesPromise = fetchMessages(MESSAGE_LIMIT, { encrypted: true }).catch(err => {
|
|
console.warn('encrypted message refresh failed; continuing without encrypted entries', err);
|
|
return [];
|
|
});
|
|
const [
|
|
nodes,
|
|
positions,
|
|
neighborTuples,
|
|
traceEntries,
|
|
messages,
|
|
telemetryEntries,
|
|
encryptedMessages
|
|
] = await Promise.all([
|
|
fetchNodes(),
|
|
positionsPromise,
|
|
neighborPromise,
|
|
tracesPromise,
|
|
fetchMessages(MESSAGE_LIMIT),
|
|
telemetryPromise,
|
|
encryptedMessagesPromise
|
|
]);
|
|
const aggregatedNodes = aggregateNodeSnapshots(nodes);
|
|
const aggregatedPositions = aggregatePositionSnapshots(positions);
|
|
const aggregatedNeighbors = aggregateNeighborSnapshots(neighborTuples);
|
|
const aggregatedTelemetry = aggregateTelemetrySnapshots(telemetryEntries);
|
|
aggregatedNodes.forEach(applyNodeNameFallback);
|
|
mergePositionsIntoNodes(aggregatedNodes, aggregatedPositions);
|
|
computeDistances(aggregatedNodes);
|
|
mergeTelemetryIntoNodes(aggregatedNodes, aggregatedTelemetry);
|
|
normalizeNodeCollection(aggregatedNodes);
|
|
allNodes = aggregatedNodes;
|
|
rebuildNodeIndex(allNodes);
|
|
const [chatMessages, encryptedChatMessages] = await Promise.all([
|
|
messageNodeHydrator.hydrate(messages, nodesById),
|
|
messageNodeHydrator.hydrate(encryptedMessages, nodesById)
|
|
]);
|
|
allMessages = Array.isArray(chatMessages) ? chatMessages : [];
|
|
allEncryptedMessages = Array.isArray(encryptedChatMessages) ? encryptedChatMessages : [];
|
|
allTelemetryEntries = aggregatedTelemetry;
|
|
allPositionEntries = aggregatedPositions;
|
|
allNeighbors = aggregatedNeighbors;
|
|
allTraces = Array.isArray(traceEntries) ? traceEntries : [];
|
|
applyFilter();
|
|
if (statusEl) {
|
|
statusEl.textContent = 'updated ' + new Date().toLocaleTimeString();
|
|
}
|
|
} catch (e) {
|
|
if (statusEl) {
|
|
statusEl.textContent = 'error: ' + e.message;
|
|
}
|
|
console.error(e);
|
|
}
|
|
}
|
|
|
|
refresh();
|
|
restartAutoRefresh();
|
|
if (refreshBtn) {
|
|
refreshBtn.addEventListener('click', refresh);
|
|
}
|
|
|
|
if (autoRefreshEl) {
|
|
autoRefreshEl.addEventListener('change', () => {
|
|
restartAutoRefresh();
|
|
if (autoRefreshEl.checked) {
|
|
refresh();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Update the count badge showing how many nodes are displayed.
|
|
*
|
|
* @param {Array<Object>} nodes Node payloads.
|
|
* @param {number} nowSec Reference timestamp.
|
|
* @returns {void}
|
|
*/
|
|
function updateCount(nodes, nowSec) {
|
|
const dayAgoSec = nowSec - 86400;
|
|
const count = nodes.filter(n => n.last_heard && Number(n.last_heard) >= dayAgoSec).length;
|
|
const text = `${baseTitle} (${count})`;
|
|
titleEl.textContent = text;
|
|
if (headerTitleTextEl) {
|
|
headerTitleTextEl.textContent = text;
|
|
} else if (headerEl) {
|
|
headerEl.textContent = text;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update the status message describing the currently rendered data.
|
|
*
|
|
* @param {Array<Object>} nodes Node payloads.
|
|
* @param {number} nowSec Reference timestamp.
|
|
* @returns {void}
|
|
*/
|
|
function updateRefreshInfo(nodes, nowSec) {
|
|
if (!refreshInfo || !isDashboardView) {
|
|
return;
|
|
}
|
|
const windows = [
|
|
{ label: 'hour', secs: 3600 },
|
|
{ label: 'day', secs: 86400 },
|
|
{ label: 'week', secs: 7 * 86400 },
|
|
];
|
|
const counts = windows.map(w => {
|
|
const c = nodes.filter(n => n.last_heard && nowSec - Number(n.last_heard) <= w.secs).length;
|
|
return `${c}/${w.label}`;
|
|
}).join(', ');
|
|
refreshInfo.textContent = `${config.channel} (${config.frequency}) — active nodes: ${counts}.`;
|
|
}
|
|
}
|