Files
skills/orchestrator/templates/popup.js
T

79 lines
3.9 KiB
JavaScript
Raw Normal View History

const Popup = (() => {
"use strict";
const GAP = 16;
const REACH = 40;
const MIN = 160;
const clamp = (value, low, high) => Math.max(low, Math.min(value, high));
const fixed = (value) => Math.round(value * 10) / 10;
function leader(anchor, box) {
const cx = anchor.left + anchor.width / 2;
const cy = anchor.top + anchor.height / 2;
const x2 = clamp(cx, box.left, box.left + box.width);
const y2 = clamp(cy, box.top, box.top + box.height);
const dx = x2 - cx;
const dy = y2 - cy;
const share = Math.min(dx ? anchor.width / 2 / Math.abs(dx) : Infinity, dy ? anchor.height / 2 / Math.abs(dy) : Infinity);
return share >= 1 ? null : {x1: fixed(cx + dx * share), y1: fixed(cy + dy * share), x2: fixed(x2), y2: fixed(y2)};
}
function vertical(anchor, size, view, left) {
const below = view.height - GAP - (anchor.top + anchor.height + REACH);
const above = anchor.top - REACH - GAP;
const down = below >= size.height || (above < size.height && below >= above);
const room = down ? below : above;
if (room < Math.min(size.height, MIN)) {
return {left, top: clamp(anchor.top + anchor.height + REACH, GAP, view.height - size.height - GAP), height: null};
}
const height = Math.min(size.height, room);
return {left, top: down ? anchor.top + anchor.height + REACH : anchor.top - REACH - height, height: height < size.height ? height : null};
}
/**
* @param anchor - the picked node's viewport box {left, top, width, height}, or null for a popup without a node.
* @param size - the popup's natural {width, height}.
* @param view - the viewport's {width, height}.
* @returns {left, top, height, line}: the popup's corner right or left of the node, else below or above it with
* height capping it to the room there (null: no cap), else below and clamped into the viewport; line {x1, y1, x2,
* y2} runs from the node's edge to the nearest point of the popup, null without a node or when the popup covers it.
*/
function place(anchor, size, view) {
if (!anchor) {
return {left: fixed(Math.max(GAP, (view.width - size.width) / 2)), top: fixed(Math.max(GAP, (view.height - size.height) / 2)), height: null, line: null};
}
const top = clamp(anchor.top + anchor.height / 2 - size.height / 2, GAP, view.height - size.height - GAP);
const left = clamp(anchor.left + anchor.width / 2 - size.width / 2, GAP, view.width - size.width - GAP);
const right = anchor.left + anchor.width + REACH;
const before = anchor.left - REACH - size.width;
const spot = right + size.width <= view.width - GAP ? {left: right, top, height: null} : before >= GAP ? {left: before, top, height: null} : vertical(anchor, size, view, left);
const box = {left: spot.left, top: spot.top, width: size.width, height: spot.height === null ? size.height : spot.height};
return {left: fixed(spot.left), top: fixed(spot.top), height: spot.height === null ? null : fixed(spot.height), line: leader(anchor, box)};
}
/**
* @param nodes - {id, label} records.
* @param query - the typed search text; blank finds nothing.
* @param limit - the most hits returned.
* @returns the first node of every distinct label containing the query, case-insensitive, labels starting with it
* first, otherwise in node order.
*/
function hits(nodes, query, limit) {
const words = String(query).trim().toLowerCase();
if (!words) {
return [];
}
const found = new Map();
for (const node of nodes) {
const at = String(node.label).toLowerCase().indexOf(words);
if (at >= 0 && !found.has(node.label)) {
found.set(node.label, {node, first: at === 0});
}
}
return [...found.values()].sort((a, b) => Number(b.first) - Number(a.first)).slice(0, limit).map((entry) => entry.node);
}
return {GAP, REACH, MIN, place, hits};
})();
if (typeof module === "object" && module.exports) {
module.exports = Popup;
}