Files
potato-mesh/web/public/assets/js/app/map-marker-node-info.js
l5y 12f1801ed2 nodes: improve charts on detail pages (#450)
* nodes: add charts to detail pages

* nodes: improve charts on detail pages

* fix ignored packet debug loggin

* run rufo

* address review comments
2025-11-14 20:17:58 +01:00

282 lines
9.8 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.
*/
/**
* Determine whether the provided value behaves like a plain object.
*
* @param {*} value Candidate value.
* @returns {boolean} True when ``value`` is a non-null object.
*/
function isObject(value) {
return value != null && typeof value === 'object';
}
/**
* Convert a value to a trimmed string when possible.
*
* @param {*} value Input value.
* @returns {string|null} Trimmed string or ``null`` when blank.
*/
function toTrimmedString(value) {
if (value == null) return null;
const str = String(value).trim();
return str.length === 0 ? null : str;
}
/**
* Attempt to coerce the provided value into a finite number.
*
* @param {*} value Raw value.
* @returns {number|null} Finite number or ``null`` when coercion fails.
*/
function toFiniteNumber(value) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null;
}
if (value == null || value === '') return null;
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
/**
* Normalise a neighbour entry so that downstream consumers can display it.
*
* @param {*} entry Raw neighbour entry.
* @returns {Object|null} Normalised neighbour reference or ``null`` when invalid.
*/
function normaliseNeighbor(entry) {
if (!isObject(entry)) return null;
const neighborId = toTrimmedString(entry.neighbor_id ?? entry.neighborId ?? entry.nodeId ?? entry.node_id);
if (!neighborId) return null;
const neighborShort = toTrimmedString(entry.neighbor_short_name ?? entry.neighborShortName ?? entry.short_name ?? entry.shortName);
const neighborLong = toTrimmedString(entry.neighbor_long_name ?? entry.neighborLongName ?? entry.long_name ?? entry.longName);
const neighborRole = toTrimmedString(entry.neighbor_role ?? entry.neighborRole ?? entry.role) || 'CLIENT';
const node = {
node_id: neighborId,
short_name: neighborShort ?? '',
long_name: neighborLong ?? '',
role: neighborRole,
};
const snr = toFiniteNumber(entry.snr);
const rxTime = toFiniteNumber(entry.rx_time ?? entry.rxTime);
const result = { node };
if (snr != null) {
result.snr = snr;
}
if (rxTime != null) {
result.rxTime = rxTime;
result.rx_time = rxTime;
}
return result;
}
/**
* Convert overlay node details into a map friendly payload.
*
* @param {*} source Raw overlay details.
* @returns {Object} Map node payload containing snake_case keys.
*/
export function overlayToPopupNode(source) {
if (!isObject(source)) {
return {
node_id: '',
node_num: null,
short_name: '',
long_name: '',
role: 'CLIENT',
neighbors: [],
};
}
const nodeId = toTrimmedString(source.nodeId ?? source.node_id ?? source.id) ?? '';
const nodeNum = toFiniteNumber(source.nodeNum ?? source.node_num ?? source.num);
const role = toTrimmedString(source.role) || 'CLIENT';
const neighbours = Array.isArray(source.neighbors)
? source.neighbors.map(normaliseNeighbor).filter(Boolean)
: [];
const payload = {
node_id: nodeId,
node_num: nodeNum,
short_name: toTrimmedString(source.shortName ?? source.short_name ?? source.name) ?? '',
long_name: toTrimmedString(source.longName ?? source.long_name ?? source.fullName ?? '') ?? '',
role,
hw_model: toTrimmedString(source.hwModel ?? source.hw_model ?? source.hardware) ?? '',
battery_level: toFiniteNumber(source.battery ?? source.battery_level),
voltage: toFiniteNumber(source.voltage),
uptime_seconds: toFiniteNumber(source.uptime ?? source.uptime_seconds),
channel_utilization: toFiniteNumber(source.channel_utilization ?? source.channelUtilization),
air_util_tx: toFiniteNumber(source.airUtil ?? source.air_util_tx),
temperature: toFiniteNumber(source.temperature),
relative_humidity: toFiniteNumber(source.humidity ?? source.relative_humidity),
barometric_pressure: toFiniteNumber(source.pressure ?? source.barometric_pressure),
telemetry_time: toFiniteNumber(source.telemetryTime ?? source.telemetry_time),
last_heard: toFiniteNumber(source.lastHeard ?? source.last_heard),
position_time: toFiniteNumber(source.positionTime ?? source.position_time),
latitude: toFiniteNumber(source.latitude),
longitude: toFiniteNumber(source.longitude),
altitude: toFiniteNumber(source.altitude),
neighbors: neighbours,
};
if (!payload.long_name && payload.short_name) {
payload.long_name = payload.short_name;
}
return payload;
}
/**
* Attach an asynchronous refresh handler to a Leaflet marker so that
* up-to-date node information is fetched whenever the marker is clicked.
*
* @param {Object} options Behaviour configuration.
* @param {Object} options.marker Leaflet marker instance supporting ``on``.
* @param {Function} options.getOverlayFallback Returns the fallback overlay payload.
* @param {Function} options.refreshNodeInformation Async function fetching node details.
* @param {Function} options.mergeOverlayDetails Merge function combining fetched and fallback details.
* @param {Function} options.createRequestToken Generates a token for cancellation tracking.
* Receives the marker anchor element and the fallback overlay payload.
* @param {Function} options.isTokenCurrent Tests whether a request token is still current.
* Receives the marker anchor element and the candidate token value.
* @param {Function} [options.showLoading] Callback invoked before refreshing.
* @param {Function} [options.showDetails] Callback invoked with merged overlay details.
* @param {Function} [options.showError] Callback invoked when refreshing fails.
* @param {Function} [options.updatePopup] Callback updating the marker popup contents.
* @param {Function} [options.shouldHandleClick] Predicate that decides whether the click should trigger a refresh.
* @returns {void}
*/
export function attachNodeInfoRefreshToMarker({
marker,
getOverlayFallback,
refreshNodeInformation,
mergeOverlayDetails,
createRequestToken,
isTokenCurrent,
showLoading,
showDetails,
showError,
updatePopup,
shouldHandleClick,
}) {
if (!isObject(marker) || typeof marker.on !== 'function') {
throw new TypeError('A Leaflet marker with an on() method is required');
}
if (typeof refreshNodeInformation !== 'function') {
throw new TypeError('A refreshNodeInformation function must be provided');
}
if (typeof mergeOverlayDetails !== 'function') {
throw new TypeError('A mergeOverlayDetails function must be provided');
}
if (typeof createRequestToken !== 'function' || typeof isTokenCurrent !== 'function') {
throw new TypeError('Token management callbacks must be provided');
}
marker.on('click', event => {
if (event && event.originalEvent) {
const original = event.originalEvent;
if (typeof original.preventDefault === 'function') {
original.preventDefault();
}
if (typeof original.stopPropagation === 'function') {
original.stopPropagation();
}
}
const fallbackOverlay = typeof getOverlayFallback === 'function' ? getOverlayFallback() : null;
const anchor = typeof marker.getElement === 'function' ? marker.getElement() : null;
if (!isObject(fallbackOverlay)) {
if (anchor && typeof showDetails === 'function') {
showDetails(anchor, {});
}
return;
}
if (typeof shouldHandleClick === 'function' && !shouldHandleClick(anchor, fallbackOverlay)) {
return;
}
if (typeof updatePopup === 'function') {
updatePopup(fallbackOverlay);
}
const nodeId = toTrimmedString(fallbackOverlay.nodeId ?? fallbackOverlay.node_id ?? fallbackOverlay.id);
const nodeNum = toFiniteNumber(fallbackOverlay.nodeNum ?? fallbackOverlay.node_num ?? fallbackOverlay.num);
if (!nodeId && nodeNum == null) {
if (anchor && typeof showDetails === 'function') {
showDetails(anchor, fallbackOverlay);
}
return;
}
const requestToken = createRequestToken(anchor, fallbackOverlay);
if (anchor && typeof showLoading === 'function') {
showLoading(anchor, fallbackOverlay);
}
const reference = { fallback: fallbackOverlay };
if (nodeId) reference.nodeId = nodeId;
if (nodeNum != null) reference.nodeNum = nodeNum;
let refreshPromise;
try {
refreshPromise = Promise.resolve(refreshNodeInformation(reference));
} catch (error) {
if (isTokenCurrent(anchor, requestToken)) {
if (anchor && typeof showError === 'function') {
showError(anchor, fallbackOverlay, error);
}
}
return;
}
refreshPromise
.then(details => {
if (!isTokenCurrent(anchor, requestToken)) {
return;
}
const merged = mergeOverlayDetails(details, fallbackOverlay);
if (typeof updatePopup === 'function') {
updatePopup(merged);
}
if (anchor && typeof showDetails === 'function') {
showDetails(anchor, merged);
}
})
.catch(error => {
if (!isTokenCurrent(anchor, requestToken)) {
return;
}
if (typeof updatePopup === 'function') {
updatePopup(fallbackOverlay);
}
if (anchor && typeof showError === 'function') {
showError(anchor, fallbackOverlay, error);
}
});
});
}
export const __testUtils = {
isObject,
toTrimmedString,
toFiniteNumber,
normaliseNeighbor,
};