/**
* Shared tooltip utilities — used by InfoWidget, panel buttons, and anywhere else
* a styled .info-widget-tooltip needs to appear on hover.
*/
let _activeTip = null;
/** Removes the currently active tooltip from the DOM. */
function _dismissActive() {
_activeTip?.remove();
_activeTip = null;
}
/**
* Creates and positions a styled tooltip near an anchor element.
* Returns the tooltip element so the caller can remove it later.
*
* @param {HTMLElement} anchor - Element to anchor the tooltip to.
* @param {string} text - Tooltip content (plain text, or HTML when html=true).
* @param {{html: boolean, extraClass: string}} [opts] - Rendering options.
* @returns {HTMLElement}
*/
export function showTooltip(anchor, text, { html = false, extraClass = null } = {}) {
_dismissActive();
const tip = document.createElement('div');
tip.className = 'info-widget-tooltip';
if (extraClass) tip.classList.add(extraClass);
if (html) tip.innerHTML = text;
else tip.textContent = text;
document.body.appendChild(tip);
const rect = anchor.getBoundingClientRect();
const tipW = tip.offsetWidth;
const tipH = tip.offsetHeight;
const gap = 6;
const top = (rect.bottom + tipH + gap <= window.innerHeight)
? rect.bottom + gap
: rect.top - tipH - gap;
const left = Math.min(rect.left, window.innerWidth - tipW - 8);
tip.style.top = `${top}px`;
tip.style.left = `${left}px`;
_activeTip = tip;
return tip;
}
/**
* Appends a pre-built tooltip element to the body and positions it near the
* cursor, clamping to the viewport. The element must already have the
* `info-widget-tooltip` class (and any extra classes) set by the caller.
*
* @param {HTMLElement} el - The tooltip element to position.
* @param {number} mouseX - current cursor clientX
* @param {number} mouseY - current cursor clientY
*/
export function positionTooltipAtCursor(el, mouseX, mouseY) {
document.body.appendChild(el);
el.style.left = `${mouseX + 12}px`;
el.style.top = `${mouseY + 16}px`;
requestAnimationFrame(() => {
const r = el.getBoundingClientRect();
if (r.right > window.innerWidth) el.style.left = `${window.innerWidth - r.width - 8}px`;
if (r.bottom > window.innerHeight) el.style.top = `${mouseY - r.height - 4}px`;
});
}
/**
* Attaches a styled tooltip to any element with a `title` attribute.
* Suppresses the native browser tooltip on hover. Safe to call multiple times.
*
* @param {HTMLElement} el - Element with a title attribute to receive a styled tooltip.
*/
export function attachTooltip(el) {
if (el._styledTooltipAttached) return;
el._styledTooltipAttached = true;
let tip = null;
el.addEventListener('mouseenter', () => {
const text = el.title;
if (!text) return;
el._savedTitle = text;
el.removeAttribute('title'); // suppress native browser tooltip
tip = showTooltip(el, text);
});
el.addEventListener('mouseleave', () => {
// Prefer any title set during the hover (e.g. by a click handler) over
// the snapshot taken at mouseenter; fall back to the snapshot if needed.
el.title = el.title || el._savedTitle || '';
el._savedTitle = null;
if (tip) {
tip.remove();
if (_activeTip === tip) _activeTip = null;
tip = null;
}
});
}
/**
* Globally replaces all native title-attribute tooltips with styled ones.
* Attaches to every current [title] element in the DOM, then watches for new
* elements (or title attributes being set) via MutationObserver.
* Call once at app startup.
*/
export function initTooltips() {
document.querySelectorAll('[title]').forEach(attachTooltip);
new MutationObserver(mutations => {
for (const mutation of mutations) {
if (mutation.type === 'childList') {
for (const node of mutation.addedNodes) {
if (node.nodeType !== 1) continue;
if (node.title) attachTooltip(node);
node.querySelectorAll('[title]').forEach(attachTooltip);
}
} else if (mutation.type === 'attributes') {
if (mutation.target.title) attachTooltip(mutation.target);
}
}
}).observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['title'],
});
}