Refine stacked short info overlays on the map (#319)

* Refine map overlays to use stacked short info panels

* Allow stacked overlays to pass neighbor clicks
This commit is contained in:
l5y
2025-10-13 14:53:43 +02:00
committed by GitHub
parent dee6ad7e4a
commit 40b6eda096
6 changed files with 902 additions and 134 deletions
@@ -57,8 +57,14 @@ test('attachNodeInfoRefreshToMarker refreshes markers with merged overlay detail
return { battery: 55.5, telemetryTime: 123, neighbors: [{ neighbor_id: '!bar', snr: 9.5 }] };
},
mergeOverlayDetails: (primary, fallback) => ({ ...fallback, ...primary }),
createRequestToken: () => ++token,
isTokenCurrent: candidate => candidate === token,
createRequestToken: el => {
assert.equal(el, anchor);
return ++token;
},
isTokenCurrent: (el, candidate) => {
assert.equal(el, anchor);
return candidate === token;
},
showLoading: (el, info) => {
assert.equal(el, anchor);
assert.equal(info.nodeId, '!foo');
@@ -119,8 +125,14 @@ test('attachNodeInfoRefreshToMarker surfaces errors with fallback overlays', asy
throw new Error('boom');
},
mergeOverlayDetails: (primary, fallback) => ({ ...fallback, ...primary }),
createRequestToken: () => ++token,
isTokenCurrent: candidate => candidate === token,
createRequestToken: el => {
assert.equal(el, anchor);
return ++token;
},
isTokenCurrent: (el, candidate) => {
assert.equal(el, anchor);
return candidate === token;
},
showLoading: () => {},
showDetails: () => {
detailCalls += 1;
@@ -158,8 +170,14 @@ test('attachNodeInfoRefreshToMarker skips refresh when identifiers are missing',
refreshed = true;
},
mergeOverlayDetails: (primary, fallback) => ({ ...fallback, ...primary }),
createRequestToken: () => ++token,
isTokenCurrent: candidate => candidate === token,
createRequestToken: el => {
assert.equal(el, anchor);
return ++token;
},
isTokenCurrent: (el, candidate) => {
assert.equal(el, anchor);
return candidate === token;
},
showLoading: () => {
assert.fail('showLoading should not run without identifiers');
},
@@ -190,7 +208,7 @@ test('attachNodeInfoRefreshToMarker honours shouldHandleClick predicate', async
},
mergeOverlayDetails: (primary, fallback) => ({ ...fallback, ...primary }),
createRequestToken: () => ++token,
isTokenCurrent: candidate => candidate === token,
isTokenCurrent: (el, candidate) => candidate === token,
shouldHandleClick: () => false,
});
@@ -0,0 +1,350 @@
/*
* Copyright (C) 2025 l5yth
*
* 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 test from 'node:test';
import assert from 'node:assert/strict';
import { createShortInfoOverlayStack } from '../short-info-overlay-manager.js';
/**
* Minimal DOM element implementation tailored for overlay manager tests.
*/
class StubElement {
/**
* @param {string} [tagName='div'] Element tag identifier.
*/
constructor(tagName = 'div') {
this.tagName = tagName.toUpperCase();
this.children = [];
this.parentNode = null;
this.style = {};
this.dataset = {};
this.className = '';
this.innerHTML = '';
this.attributes = new Map();
this.eventHandlers = new Map();
this._rect = { left: 0, top: 0, width: 120, height: 80 };
}
/**
* Append ``child`` to the element.
*
* @param {StubElement} child Child node to append.
* @returns {StubElement} Appended node.
*/
appendChild(child) {
this.children.push(child);
child.parentNode = this;
return child;
}
/**
* Remove ``child`` from the element.
*
* @param {StubElement} child Child node to remove.
* @returns {void}
*/
removeChild(child) {
const idx = this.children.indexOf(child);
if (idx >= 0) {
this.children.splice(idx, 1);
child.parentNode = null;
}
}
/**
* Remove the element from its parent tree.
*
* @returns {void}
*/
remove() {
if (this.parentNode) {
this.parentNode.removeChild(this);
}
}
/**
* Assign an attribute to the element.
*
* @param {string} name Attribute identifier.
* @param {string} value Stored attribute value.
* @returns {void}
*/
setAttribute(name, value) {
this.attributes.set(name, String(value));
if (name === 'class' || name === 'className') {
this.className = String(value);
}
}
/**
* Remove an attribute from the element.
*
* @param {string} name Attribute identifier.
* @returns {void}
*/
removeAttribute(name) {
this.attributes.delete(name);
if (name === 'class' || name === 'className') {
this.className = '';
}
}
/**
* Register an event handler for the element.
*
* @param {string} event Event identifier.
* @param {Function} handler Handler invoked during tests.
* @returns {void}
*/
addEventListener(event, handler) {
this.eventHandlers.set(event, handler);
}
/**
* Retrieve the first descendant matching a simple class selector.
*
* @param {string} selector CSS selector (class only).
* @returns {?StubElement} Matching element or ``null``.
*/
querySelector(selector) {
if (!selector || selector[0] !== '.') {
return null;
}
const className = selector.slice(1);
return this._findByClass(className);
}
/**
* Recursively search for an element with ``className``.
*
* @param {string} className Class identifier to match.
* @returns {?StubElement} Matching element or ``null``.
*/
_findByClass(className) {
const classes = (this.className || '').split(/\s+/).filter(Boolean);
if (classes.includes(className)) {
return this;
}
for (const child of this.children) {
const found = child._findByClass(className);
if (found) {
return found;
}
}
return null;
}
/**
* Determine whether ``candidate`` is a descendant of the element.
*
* @param {StubElement} candidate Potential child node.
* @returns {boolean} ``true`` when the node is contained within the element.
*/
contains(candidate) {
if (this === candidate) {
return true;
}
for (const child of this.children) {
if (child.contains(candidate)) {
return true;
}
}
return false;
}
/**
* Return the mock bounding rectangle for the element.
*
* @returns {{ left: number, top: number, width: number, height: number }}
*/
getBoundingClientRect() {
return { ...this._rect };
}
/**
* Override the bounding rectangle used during positioning tests.
*
* @param {{ left?: number, top?: number, width?: number, height?: number }} rect
* @returns {void}
*/
setBoundingRect(rect) {
this._rect = { ...this._rect, ...rect };
}
/**
* Create a deep clone of the element.
*
* @param {boolean} [deep=false] When ``true`` clone the children as well.
* @returns {StubElement} Cloned element instance.
*/
cloneNode(deep = false) {
const clone = new StubElement(this.tagName);
clone.className = this.className;
clone.style = { ...this.style };
clone.dataset = { ...this.dataset };
clone.innerHTML = this.innerHTML;
clone._rect = { ...this._rect };
clone.attributes = new Map(this.attributes);
if (deep) {
for (const child of this.children) {
clone.appendChild(child.cloneNode(true));
}
}
return clone;
}
/**
* Locate the nearest ancestor carrying ``selector``.
*
* @param {string} selector CSS selector (class only).
* @returns {?StubElement} Matching ancestor or ``null``.
*/
closest(selector) {
if (!selector || selector[0] !== '.') {
return null;
}
const className = selector.slice(1);
let current = this;
while (current) {
const classes = (current.className || '').split(/\s+/).filter(Boolean);
if (classes.includes(className)) {
return current;
}
current = current.parentNode;
}
return null;
}
}
/**
* Build a minimal DOM document stub for overlay manager tests.
*
* @returns {{ document: Document, window: Window, factory: Function, anchor: StubElement, body: StubElement }}
*/
function createStubDom() {
const body = new StubElement('body');
body.contains = body.contains.bind(body);
const document = {
body,
documentElement: { clientWidth: 640, clientHeight: 480 },
createElement(tagName) {
return new StubElement(tagName);
},
getElementById() {
return null;
},
};
const window = {
scrollX: 10,
scrollY: 20,
innerWidth: 640,
innerHeight: 480,
requestAnimationFrame(callback) {
callback();
},
};
function factory() {
const overlay = document.createElement('div');
overlay.className = 'short-info-overlay';
const closeButton = document.createElement('button');
closeButton.className = 'short-info-close';
const content = document.createElement('div');
content.className = 'short-info-content';
overlay.appendChild(closeButton);
overlay.appendChild(content);
return { overlay, closeButton, content };
}
const anchor = document.createElement('span');
anchor.setBoundingRect({ left: 40, top: 50, width: 16, height: 16 });
body.appendChild(anchor);
return { document, window, factory, anchor, body };
}
test('render opens overlays and positions them relative to anchors', () => {
const { document, window, factory, anchor, body } = createStubDom();
const stack = createShortInfoOverlayStack({ document, window, factory });
stack.render(anchor, '<strong>Node</strong>');
const open = stack.getOpenOverlays();
assert.equal(open.length, 1);
const overlay = open[0].element;
assert.equal(overlay.parentNode, body);
const content = overlay.querySelector('.short-info-content');
assert.ok(content);
assert.equal(content.innerHTML, '<strong>Node</strong>');
assert.equal(overlay.style.left, '50px');
assert.equal(overlay.style.top, '70px');
});
test('request tokens track anchors independently', () => {
const { document, window, factory, anchor } = createStubDom();
const stack = createShortInfoOverlayStack({ document, window, factory });
const token1 = stack.incrementRequestToken(anchor);
const token2 = stack.incrementRequestToken(anchor);
assert.equal(token2, token1 + 1);
stack.render(anchor, 'Loading…');
assert.equal(stack.isTokenCurrent(anchor, token2), true);
stack.close(anchor);
assert.equal(stack.isTokenCurrent(anchor, token2), false);
});
test('overlays stack and close independently', () => {
const { document, window, factory, anchor, body } = createStubDom();
const stack = createShortInfoOverlayStack({ document, window, factory });
const secondAnchor = document.createElement('span');
secondAnchor.setBoundingRect({ left: 200, top: 120 });
body.appendChild(secondAnchor);
stack.render(anchor, 'First');
stack.render(secondAnchor, 'Second');
const open = stack.getOpenOverlays();
assert.equal(open.length, 2);
assert.equal(stack.isOpen(anchor), true);
assert.equal(stack.isOpen(secondAnchor), true);
stack.close(anchor);
assert.equal(stack.isOpen(anchor), false);
assert.equal(stack.isOpen(secondAnchor), true);
stack.closeAll();
assert.equal(stack.getOpenOverlays().length, 0);
});
test('cleanupOrphans removes overlays whose anchors disappear', () => {
const { document, window, factory, anchor } = createStubDom();
const stack = createShortInfoOverlayStack({ document, window, factory });
stack.render(anchor, 'Orphaned');
anchor.remove();
stack.cleanupOrphans();
assert.equal(stack.getOpenOverlays().length, 0);
});
test('containsNode recognises overlay descendants', () => {
const { document, window, factory, anchor } = createStubDom();
const stack = createShortInfoOverlayStack({ document, window, factory });
stack.render(anchor, 'Descendant');
const [entry] = stack.getOpenOverlays();
const content = entry.element.querySelector('.short-info-content');
assert.ok(stack.containsNode(content));
const stray = new StubElement('div');
assert.equal(stack.containsNode(stray), false);
});
test('rendered overlays do not swallow click events by default', () => {
const { document, window, factory, anchor } = createStubDom();
const stack = createShortInfoOverlayStack({ document, window, factory });
stack.render(anchor, 'Event test');
const [entry] = stack.getOpenOverlays();
assert.ok(entry);
assert.equal(entry.element.eventHandlers.has('click'), false);
});
+41 -119
View File
@@ -17,6 +17,7 @@
import { computeBoundingBox, computeBoundsForPoints, haversineDistanceKm } from './map-bounds.js';
import { createMapAutoFitController } from './map-auto-fit-controller.js';
import { attachNodeInfoRefreshToMarker, overlayToPopupNode } from './map-marker-node-info.js';
import { createShortInfoOverlayStack } from './short-info-overlay-manager.js';
import { refreshNodeInformation } from './node-details.js';
/**
@@ -47,9 +48,8 @@ export function initializeApp(config) {
const infoOverlay = document.getElementById('infoOverlay');
const infoClose = document.getElementById('infoClose');
const infoDialog = infoOverlay ? infoOverlay.querySelector('.info-dialog') : null;
const shortInfoOverlay = document.getElementById('shortInfoOverlay');
const shortInfoClose = shortInfoOverlay ? shortInfoOverlay.querySelector('.short-info-close') : null;
const shortInfoContent = shortInfoOverlay ? shortInfoOverlay.querySelector('.short-info-content') : 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;
@@ -100,10 +100,6 @@ export function initializeApp(config) {
let allNeighbors = [];
/** @type {Map<string, Object>} */
let nodesById = new Map();
/** @type {HTMLElement|null} */
let shortInfoAnchor = null;
/** @type {number} */
let shortInfoRequestToken = 0;
/** @type {string|undefined} */
let lastChatDate;
const NODE_LIMIT = 1000;
@@ -1396,14 +1392,6 @@ export function initializeApp(config) {
});
}
if (shortInfoClose) {
shortInfoClose.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
closeShortInfoOverlay();
});
}
document.addEventListener('click', event => {
const shortTarget = event.target.closest('.short-name');
if (
@@ -1450,8 +1438,8 @@ export function initializeApp(config) {
fallbackInfo.shortName = fallbackDetails.shortName;
}
if (shortInfoOverlay && !shortInfoOverlay.hidden && shortInfoAnchor === shortTarget) {
closeShortInfoOverlay();
if (overlayStack.isOpen(shortTarget)) {
overlayStack.close(shortTarget);
return;
}
@@ -1465,12 +1453,12 @@ export function initializeApp(config) {
return;
}
const requestId = ++shortInfoRequestToken;
const requestId = overlayStack.incrementRequestToken(shortTarget);
showShortInfoLoading(shortTarget, fallbackDetails);
refreshNodeInformation({ nodeId: nodeId || undefined, nodeNum: nodeNum ?? undefined, fallback: fallbackInfo })
.then(details => {
if (requestId !== shortInfoRequestToken) return;
if (!overlayStack.isTokenCurrent(shortTarget, requestId)) return;
const overlayDetails = mergeOverlayDetails(details, fallbackInfo);
if (!overlayDetails.shortName && shortTarget.textContent) {
overlayDetails.shortName = shortTarget.textContent.replace(/\u00a0/g, ' ').trim();
@@ -1479,7 +1467,7 @@ export function initializeApp(config) {
})
.catch(err => {
console.warn('Failed to refresh node information', err);
if (requestId !== shortInfoRequestToken) return;
if (!overlayStack.isTokenCurrent(shortTarget, requestId)) return;
const overlayDetails = mergeOverlayDetails(null, fallbackInfo);
if (!overlayDetails.shortName && shortTarget.textContent) {
overlayDetails.shortName = shortTarget.textContent.replace(/\u00a0/g, ' ').trim();
@@ -1491,21 +1479,17 @@ export function initializeApp(config) {
if (event.target.closest('.neighbor-connection-line')) {
return;
}
if (shortInfoOverlay && !shortInfoOverlay.hidden && !shortInfoOverlay.contains(event.target)) {
closeShortInfoOverlay();
}
});
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && shortInfoOverlay && !shortInfoOverlay.hidden) {
closeShortInfoOverlay();
if (overlayStack.containsNode(event.target)) {
return;
}
overlayStack.closeAll();
});
window.addEventListener('resize', () => {
if (shortInfoOverlay && !shortInfoOverlay.hidden) {
requestAnimationFrame(positionShortInfoOverlay);
}
overlayStack.positionAll();
});
window.addEventListener('scroll', () => {
overlayStack.positionAll();
});
// --- Helpers ---
@@ -1885,54 +1869,11 @@ export function initializeApp(config) {
* @returns {void}
*/
function showShortInfoLoading(target, info) {
if (!shortInfoOverlay || !shortInfoContent) return;
if (!target) return;
const normalized = normalizeOverlaySource(info || {});
const heading = normalized.longName || normalized.shortName || normalized.nodeId || '';
const headingHtml = heading ? `<strong>${escapeHtml(heading)}</strong><br/>` : '';
shortInfoContent.innerHTML = `${headingHtml}Loading…`;
shortInfoAnchor = target;
shortInfoOverlay.hidden = false;
shortInfoOverlay.style.visibility = 'hidden';
requestAnimationFrame(positionShortInfoOverlay);
}
/**
* Hide the short-info overlay used for inline node details.
*
* @returns {void}
*/
function closeShortInfoOverlay() {
shortInfoRequestToken += 1;
if (!shortInfoOverlay) return;
shortInfoOverlay.hidden = true;
shortInfoOverlay.style.visibility = 'visible';
shortInfoAnchor = null;
}
/**
* Position the short-info overlay near its anchor element.
*
* @returns {void}
*/
function positionShortInfoOverlay() {
if (!shortInfoOverlay || shortInfoOverlay.hidden || !shortInfoAnchor) return;
if (!document.body.contains(shortInfoAnchor)) {
closeShortInfoOverlay();
return;
}
const rect = shortInfoAnchor.getBoundingClientRect();
const overlayRect = shortInfoOverlay.getBoundingClientRect();
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = document.documentElement.clientHeight;
let left = rect.left + window.scrollX;
let top = rect.top + window.scrollY;
const maxLeft = window.scrollX + viewportWidth - overlayRect.width - 8;
const maxTop = window.scrollY + viewportHeight - overlayRect.height - 8;
left = Math.max(window.scrollX + 8, Math.min(left, maxLeft));
top = Math.max(window.scrollY + 8, Math.min(top, maxTop));
shortInfoOverlay.style.left = `${left}px`;
shortInfoOverlay.style.top = `${top}px`;
shortInfoOverlay.style.visibility = 'visible';
overlayStack.render(target, `${headingHtml}Loading…`);
}
/**
@@ -1943,7 +1884,7 @@ export function initializeApp(config) {
* @returns {void}
*/
function openShortInfoOverlay(target, info) {
if (!shortInfoOverlay || !shortInfoContent || !info) return;
if (!target || !info) return;
const overlayInfo = normalizeOverlaySource(info);
if (!overlayInfo.role || overlayInfo.role === '') {
overlayInfo.role = 'CLIENT';
@@ -1996,11 +1937,7 @@ export function initializeApp(config) {
if (neighborLineHtml) {
lines.push(neighborLineHtml);
}
shortInfoContent.innerHTML = lines.join('<br/>');
shortInfoAnchor = target;
shortInfoOverlay.hidden = false;
shortInfoOverlay.style.visibility = 'hidden';
requestAnimationFrame(positionShortInfoOverlay);
overlayStack.render(target, lines.join('<br/>'));
}
/**
@@ -2011,7 +1948,7 @@ export function initializeApp(config) {
* @returns {void}
*/
function openNeighborOverlay(target, segment) {
if (!shortInfoOverlay || !shortInfoContent || !segment) return;
if (!target || !segment) return;
const nodeName = shortInfoValueOrDash(segment.sourceDisplayName || segment.sourceId || '');
const snrText = shortInfoValueOrDash(formatSnrDisplay(segment.snr));
const sourceShortHtml = renderShortHtml(
@@ -2032,11 +1969,7 @@ export function initializeApp(config) {
const neighborLine = `${targetShortHtml} [${escapeHtml(neighborFullName)}]`;
lines.push(neighborLine);
lines.push(`SNR: ${escapeHtml(snrText)}`);
shortInfoContent.innerHTML = lines.join('<br/>');
shortInfoAnchor = target;
shortInfoOverlay.hidden = false;
shortInfoOverlay.style.visibility = 'hidden';
requestAnimationFrame(positionShortInfoOverlay);
overlayStack.render(target, lines.join('<br/>'));
}
/**
@@ -2669,9 +2602,7 @@ export function initializeApp(config) {
frag.appendChild(tr);
}
tb.replaceChildren(frag);
if (shortInfoOverlay && shortInfoAnchor && !document.body.contains(shortInfoAnchor)) {
closeShortInfoOverlay();
}
overlayStack.cleanupOrphans();
}
/**
@@ -2800,8 +2731,8 @@ export function initializeApp(config) {
: null;
const anchorEl = polyline.getElement() || clickTarget;
if (!anchorEl) return;
if (shortInfoOverlay && !shortInfoOverlay.hidden && shortInfoAnchor === anchorEl) {
closeShortInfoOverlay();
if (overlayStack.isOpen(anchorEl)) {
overlayStack.close(anchorEl);
return;
}
openNeighborOverlay(anchorEl, segment);
@@ -2838,37 +2769,28 @@ export function initializeApp(config) {
});
const fallbackOverlayProvider = () => mergeOverlayDetails(null, n);
const initialOverlay = fallbackOverlayProvider();
const initialPopupHtml = buildMapPopupHtml(overlayToPopupNode(initialOverlay), nowSec);
marker.bindPopup(initialPopupHtml);
let markerToken = 0;
marker.addTo(markersLayer);
pts.push([lat, lon]);
const updateMarkerPopup = overlayDetails => {
const popupNode = overlayToPopupNode(overlayDetails);
const html = buildMapPopupHtml(popupNode, Math.floor(Date.now() / 1000));
if (typeof marker.setPopupContent === 'function') {
marker.setPopupContent(html);
return;
}
if (typeof marker.getPopup === 'function') {
const popup = marker.getPopup();
if (popup && typeof popup.setContent === 'function') {
popup.setContent(html);
return;
}
}
marker.bindPopup(html);
};
attachNodeInfoRefreshToMarker({
marker,
getOverlayFallback: fallbackOverlayProvider,
refreshNodeInformation,
mergeOverlayDetails,
createRequestToken: () => ++shortInfoRequestToken,
isTokenCurrent: token => token === shortInfoRequestToken,
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);
@@ -2885,11 +2807,10 @@ export function initializeApp(config) {
openShortInfoOverlay(anchor, info);
}
},
updatePopup: updateMarkerPopup,
shouldHandleClick: anchor => {
if (!anchor) return true;
if (shortInfoOverlay && !shortInfoOverlay.hidden && shortInfoAnchor === anchor) {
closeShortInfoOverlay();
if (overlayStack.isOpen(anchor)) {
overlayStack.close(anchor);
return false;
}
return true;
@@ -2905,6 +2826,7 @@ export function initializeApp(config) {
});
fitMapToBounds(bounds, { animate: false, paddingPx: AUTO_FIT_PADDING_PX });
}
overlayStack.cleanupOrphans();
}
/**
@@ -149,7 +149,9 @@ export function overlayToPopupNode(source) {
* @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.
@@ -222,7 +224,7 @@ export function attachNodeInfoRefreshToMarker({
return;
}
const requestToken = createRequestToken();
const requestToken = createRequestToken(anchor, fallbackOverlay);
if (anchor && typeof showLoading === 'function') {
showLoading(anchor, fallbackOverlay);
@@ -236,7 +238,7 @@ export function attachNodeInfoRefreshToMarker({
try {
refreshPromise = Promise.resolve(refreshNodeInformation(reference));
} catch (error) {
if (isTokenCurrent(requestToken)) {
if (isTokenCurrent(anchor, requestToken)) {
if (anchor && typeof showError === 'function') {
showError(anchor, fallbackOverlay, error);
}
@@ -246,7 +248,7 @@ export function attachNodeInfoRefreshToMarker({
refreshPromise
.then(details => {
if (!isTokenCurrent(requestToken)) {
if (!isTokenCurrent(anchor, requestToken)) {
return;
}
const merged = mergeOverlayDetails(details, fallbackOverlay);
@@ -258,7 +260,7 @@ export function attachNodeInfoRefreshToMarker({
}
})
.catch(error => {
if (!isTokenCurrent(requestToken)) {
if (!isTokenCurrent(anchor, requestToken)) {
return;
}
if (typeof updatePopup === 'function') {
@@ -0,0 +1,474 @@
/*
* Copyright (C) 2025 l5yth
*
* 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.
*/
const DEFAULT_TEMPLATE_ID = 'shortInfoOverlayTemplate';
/**
* Determine whether a value behaves like a DOM element that can host overlays.
*
* @param {*} candidate Potential anchor element.
* @returns {boolean} ``true`` when the candidate exposes the required DOM API.
*/
function isValidAnchor(candidate) {
return (
candidate != null &&
typeof candidate === 'object' &&
typeof candidate.getBoundingClientRect === 'function'
);
}
/**
* Create a factory that instantiates overlay DOM nodes.
*
* @param {Document} document Host document reference.
* @param {?Element} template Template element cloned for each overlay.
* @returns {Function} Factory generating overlay nodes with close/content refs.
*/
function createDefaultOverlayFactory(document, template) {
const templateNode =
template && template.content && template.content.firstElementChild
? template.content.firstElementChild
: null;
return () => {
let overlay;
if (templateNode && typeof templateNode.cloneNode === 'function') {
overlay = templateNode.cloneNode(true);
} else {
overlay = document.createElement('div');
overlay.className = 'short-info-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'false');
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.className = 'short-info-close';
closeButton.setAttribute('aria-label', 'Close node details');
closeButton.textContent = '×';
const content = document.createElement('div');
content.className = 'short-info-content';
overlay.appendChild(closeButton);
overlay.appendChild(content);
}
const closeButton =
typeof overlay.querySelector === 'function'
? overlay.querySelector('.short-info-close')
: null;
const content =
typeof overlay.querySelector === 'function'
? overlay.querySelector('.short-info-content')
: null;
return { overlay, closeButton, content };
};
}
/**
* Create a no-op overlay stack used when the DOM primitives are unavailable.
*
* @returns {Object} Overlay stack interface with inert behaviour.
*/
function createNoopOverlayStack() {
return {
render() {},
close() {},
closeAll() {},
isOpen() {
return false;
},
containsNode() {
return false;
},
positionAll() {},
cleanupOrphans() {},
incrementRequestToken() {
return 0;
},
isTokenCurrent() {
return false;
},
getOpenOverlays() {
return [];
},
};
}
/**
* Create a stack manager that renders and positions short-info overlays.
*
* @param {{
* document?: Document,
* window?: Window,
* templateId?: string,
* template?: Element,
* factory?: Function
* }} [options] Overlay configuration and host references.
* @returns {{
* render: (anchor: Element, html: string) => void,
* close: (anchor: Element) => void,
* closeAll: () => void,
* isOpen: (anchor: Element) => boolean,
* containsNode: (node: Node) => boolean,
* positionAll: () => void,
* cleanupOrphans: () => void,
* incrementRequestToken: (anchor: Element) => number,
* isTokenCurrent: (anchor: Element, token: number) => boolean,
* getOpenOverlays: () => Array<{ anchor: Element, element: Element }>
* }} Overlay stack interface.
*/
export function createShortInfoOverlayStack(options = {}) {
const doc = options.document || globalThis.document || null;
const win = options.window || globalThis.window || null;
if (!doc || !doc.body) {
return createNoopOverlayStack();
}
const template =
options.template !== undefined
? options.template
: doc.getElementById(options.templateId || DEFAULT_TEMPLATE_ID);
const overlayFactory =
typeof options.factory === 'function'
? options.factory
: createDefaultOverlayFactory(doc, template);
const overlayStates = new Map();
const overlayOrder = [];
/**
* Remove an overlay element from the DOM tree.
*
* @param {Element} element Overlay root element.
* @returns {void}
*/
function detachOverlayElement(element) {
if (!element) return;
if (typeof element.remove === 'function') {
element.remove();
return;
}
if (element.parentNode && typeof element.parentNode.removeChild === 'function') {
element.parentNode.removeChild(element);
}
}
/**
* Create or retrieve the overlay state associated with ``anchor``.
*
* @param {Element} anchor Anchor element.
* @returns {{
* anchor: Element,
* element: Element,
* content: Element,
* closeButton: Element,
* requestToken: number
* }|null} Overlay state or ``null`` when creation fails.
*/
function ensureState(anchor) {
if (!isValidAnchor(anchor)) {
return null;
}
let state = overlayStates.get(anchor);
if (state) {
return state;
}
const created = overlayFactory();
if (!created || !created.overlay || !created.content) {
return null;
}
const overlayEl = created.overlay;
const closeButton = created.closeButton || null;
const contentEl = created.content;
if (typeof overlayEl.setAttribute === 'function') {
overlayEl.setAttribute('data-short-info-overlay', '');
}
if (closeButton && typeof closeButton.addEventListener === 'function') {
closeButton.addEventListener('click', event => {
if (event) {
if (typeof event.preventDefault === 'function') {
event.preventDefault();
}
if (typeof event.stopPropagation === 'function') {
event.stopPropagation();
}
}
close(anchor);
});
}
if (typeof doc.body.appendChild === 'function') {
doc.body.appendChild(overlayEl);
}
state = {
anchor,
element: overlayEl,
content: contentEl,
closeButton,
requestToken: 0,
};
overlayStates.set(anchor, state);
overlayOrder.push(state);
return state;
}
/**
* Remove the overlay state associated with ``anchor``.
*
* @param {Element} anchor Anchor element.
* @returns {void}
*/
function removeState(anchor) {
const state = overlayStates.get(anchor);
if (!state) return;
overlayStates.delete(anchor);
const index = overlayOrder.indexOf(state);
if (index >= 0) {
overlayOrder.splice(index, 1);
}
detachOverlayElement(state.element);
}
/**
* Position an overlay relative to its anchor element.
*
* @param {{ anchor: Element, element: Element }} state Overlay state entry.
* @returns {void}
*/
function positionState(state) {
if (!state || !state.anchor || !state.element) {
return;
}
if (!doc.body.contains(state.anchor)) {
close(state.anchor);
return;
}
const rect = state.anchor.getBoundingClientRect();
const overlayRect =
typeof state.element.getBoundingClientRect === 'function'
? state.element.getBoundingClientRect()
: { width: 0, height: 0 };
const viewportWidth =
(doc.documentElement && doc.documentElement.clientWidth) ||
(win && typeof win.innerWidth === 'number' ? win.innerWidth : 0);
const viewportHeight =
(doc.documentElement && doc.documentElement.clientHeight) ||
(win && typeof win.innerHeight === 'number' ? win.innerHeight : 0);
const scrollX = (win && typeof win.scrollX === 'number' ? win.scrollX : 0) || 0;
const scrollY = (win && typeof win.scrollY === 'number' ? win.scrollY : 0) || 0;
let left = rect.left + scrollX;
let top = rect.top + scrollY;
if (viewportWidth > 0) {
const maxLeft = scrollX + viewportWidth - overlayRect.width - 8;
left = Math.max(scrollX + 8, Math.min(left, maxLeft));
}
if (viewportHeight > 0) {
const maxTop = scrollY + viewportHeight - overlayRect.height - 8;
top = Math.max(scrollY + 8, Math.min(top, maxTop));
}
if (state.element.style) {
state.element.style.left = `${left}px`;
state.element.style.top = `${top}px`;
state.element.style.visibility = 'visible';
state.element.style.position = state.element.style.position || 'absolute';
}
}
/**
* Schedule positioning of an overlay for the next animation frame.
*
* @param {{ anchor: Element, element: Element }} state Overlay state entry.
* @returns {void}
*/
function schedulePosition(state) {
if (!state || !state.element) return;
if (state.element.style) {
state.element.style.visibility = 'hidden';
}
const raf = (win && win.requestAnimationFrame) || globalThis.requestAnimationFrame;
if (typeof raf === 'function') {
raf(() => positionState(state));
} else {
setTimeout(() => positionState(state), 16);
}
}
/**
* Render overlay content anchored to the provided element.
*
* @param {Element} anchor Anchor element driving overlay placement.
* @param {string} html Inner HTML displayed in the overlay body.
* @returns {void}
*/
function render(anchor, html) {
const state = ensureState(anchor);
if (!state) {
return;
}
if (state.content && typeof state.content.innerHTML === 'string') {
state.content.innerHTML = html;
}
if (state.element && typeof state.element.removeAttribute === 'function') {
state.element.removeAttribute('hidden');
}
schedulePosition(state);
}
/**
* Close the overlay associated with ``anchor``.
*
* @param {Element} anchor Anchor element whose overlay should be removed.
* @returns {void}
*/
function close(anchor) {
const state = overlayStates.get(anchor);
if (!state) return;
state.requestToken += 1;
removeState(anchor);
}
/**
* Determine whether an overlay for ``anchor`` is currently open.
*
* @param {Element} anchor Anchor element to test.
* @returns {boolean} ``true`` when an overlay exists for the anchor.
*/
function isOpen(anchor) {
return overlayStates.has(anchor);
}
/**
* Close every active overlay.
*
* @returns {void}
*/
function closeAll() {
const anchors = Array.from(overlayStates.keys());
for (const anchor of anchors) {
close(anchor);
}
}
/**
* Test whether the provided DOM node belongs to any overlay.
*
* @param {Node} node Candidate DOM node.
* @returns {boolean} ``true`` when the node is inside an overlay.
*/
function containsNode(node) {
if (!node) return false;
for (const state of overlayStates.values()) {
if (state.element && typeof state.element.contains === 'function') {
if (state.element.contains(node)) {
return true;
}
}
}
return false;
}
/**
* Reposition all overlays based on the latest viewport metrics.
*
* @returns {void}
*/
function positionAll() {
for (const state of overlayStates.values()) {
positionState(state);
}
}
/**
* Remove overlays whose anchors are no longer part of the document body.
*
* @returns {void}
*/
function cleanupOrphans() {
for (const state of Array.from(overlayStates.values())) {
if (!doc.body.contains(state.anchor)) {
close(state.anchor);
}
}
}
/**
* Increment and return the request token for the provided anchor.
*
* @param {Element} anchor Anchor whose request token should be updated.
* @returns {number} Updated token value.
*/
function incrementRequestToken(anchor) {
const state = ensureState(anchor);
if (!state) {
return 0;
}
state.requestToken += 1;
return state.requestToken;
}
/**
* Determine whether ``token`` is still current for ``anchor``.
*
* @param {Element} anchor Anchor element associated with the request.
* @param {number} token Token obtained from ``incrementRequestToken``.
* @returns {boolean} ``true`` when the token is current.
*/
function isTokenCurrent(anchor, token) {
const state = overlayStates.get(anchor);
if (!state) {
return false;
}
return state.requestToken === token;
}
/**
* Retrieve diagnostic information about open overlays.
*
* @returns {Array<{ anchor: Element, element: Element }>}
*/
function getOpenOverlays() {
return overlayOrder.map(state => ({ anchor: state.anchor, element: state.element }));
}
return {
render,
close,
closeAll,
isOpen,
containsNode,
positionAll,
cleanupOrphans,
incrementRequestToken,
isTokenCurrent,
getOpenOverlays,
};
}
export const __testUtils = {
isValidAnchor,
createDefaultOverlayFactory,
createNoopOverlayStack,
};
+6 -4
View File
@@ -189,10 +189,12 @@
<tbody></tbody>
</table>
<div id="shortInfoOverlay" class="short-info-overlay" role="dialog" hidden>
<button type="button" class="short-info-close" aria-label="Close node details">×</button>
<div class="short-info-content"></div>
</div>
<template id="shortInfoOverlayTemplate">
<div class="short-info-overlay" role="dialog" aria-modal="false">
<button type="button" class="short-info-close" aria-label="Close node details">×</button>
<div class="short-info-content"></div>
</div>
</template>
<footer>
PotatoMesh