Files
potato-mesh/web/public/assets/js/app/main.js
l5y 7f40abf92a web: establish menu structure (#597)
* web: establish menu structure

* web: cover missing unit test vectors

* web: fix tests
2026-01-05 21:18:51 +01:00

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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* 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}>?&nbsp;&nbsp;&nbsp;</span>`;
}
const padded = escapeHtml(String(short).padStart(4, ' ')).replace(/ /g, '&nbsp;');
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}.`;
}
}