79 lines
3.9 KiB
JavaScript
79 lines
3.9 KiB
JavaScript
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;
|
||
|
|
}
|