mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-07-06 01:42:11 +02:00
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:
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user