import { LABEL_SEPARATOR, LIST_SEPARATOR } from "#configuration/strings/ontology.strings"; import { MILLISECONDS, REFERENCE_CHAIN_MS, REFERENCE_CHAIN_PROPERTY, REFERENCE_CLASS, REFERENCE_EDGES_CLASS, REFERENCE_LABEL_CLASS, REFERENCE_RELATION_CLASS, } from "#configuration/constants/reference.constants"; import { NEXT_STEP_LABEL, OF_STEPS_LABEL, PREVIOUS_STEP_LABEL, STEP_LABEL } from "#configuration/strings/walk.strings"; import { WALK_CELL_SELECTOR, WALK_CURRENT_CLASS, WALK_REF_ATTRIBUTE } from "#configuration/constants/anatomy.constants"; import { armLink, clampPanel, createRecordPanel, placePanel } from "#presentation/components/reference.component"; import { cellRecord, cellText, loadSnippet } from "#presentation/components/walk.component"; import { clearChildren, createElement } from "#core/factories/element.factory"; import { ANCHOR_PREFIX } from "#configuration/constants/document.constants"; import type { Disposer } from "#types/base.types"; import { OPEN_CLASS } from "#configuration/constants/element.constants"; import { WALK_PANEL_ID } from "#core/ids/anatomy.ids"; import type { WalkCellView } from "#types/anatomy.types"; const sharedPanel = function sharedPanel(): HTMLElement { const existing = document.getElementById(WALK_PANEL_ID); if (existing !== null) { return existing; } const panel = createElement("div", { attributes: { id: WALK_PANEL_ID }, className: REFERENCE_CLASS }); panel.style.setProperty(REFERENCE_CHAIN_PROPERTY, String(REFERENCE_CHAIN_MS) + MILLISECONDS); document.body.append(panel); return panel; }; export class WalkInspector { private readonly panel = sharedPanel(); private readonly polygons: ReadonlyMap; private readonly cells: readonly WalkCellView[]; private readonly status: HTMLElement; private current = -1; private timer: ReturnType | null = null; private disarm: Disposer | null = null; public constructor(element: HTMLElement, status: HTMLElement, cells: readonly WalkCellView[]) { this.cells = cells; this.status = status; this.polygons = new Map( [...element.querySelectorAll(WALK_CELL_SELECTOR)].map((polygon) => [ polygon.getAttribute(WALK_REF_ATTRIBUTE) ?? "", polygon, ]), ); } public indexAt(target: EventTarget | null): number { const polygon = target instanceof Element ? target.closest(WALK_CELL_SELECTOR) : null; const ref = polygon?.getAttribute(WALK_REF_ATTRIBUTE) ?? null; return ref === null ? -1 : this.cells.findIndex((cell) => cell.ref === ref); } public schedule(action: () => void, delay: number): void { this.cancel(); this.timer = setTimeout(action, delay); } public cancel(): void { if (this.timer !== null) { clearTimeout(this.timer); this.timer = null; } if (this.disarm !== null) { this.disarm(); this.disarm = null; } } public show(index: number, replace: boolean): void { const cell = this.cells[index]; const polygon = this.polygons.get(cell?.ref ?? ""); if (cell === undefined || polygon === undefined) { return; } this.cancel(); this.polygons.get(this.cells[this.current]?.ref ?? "")?.classList.remove(WALK_CURRENT_CLASS); this.current = index; polygon.classList.add(WALK_CURRENT_CLASS); this.status.textContent = cellText(cell); clearChildren(this.panel); const step = this.stepRow(index); this.panel.append(...createRecordPanel(cellRecord(cell)), step); this.panel.classList.add(OPEN_CLASS); if (replace) { placePanel(this.panel, polygon); } clampPanel(this.panel); void loadSnippet(cell).then((snippet) => { if (snippet !== null && this.current === index) { step.before(snippet); clampPanel(this.panel); } }); } public hide(): void { this.cancel(); this.polygons.get(this.cells[this.current]?.ref ?? "")?.classList.remove(WALK_CURRENT_CLASS); this.current = -1; this.panel.classList.remove(OPEN_CLASS); clearChildren(this.panel); } public isOpen(): boolean { return this.current !== -1; } public holds(node: EventTarget | null): boolean { return node instanceof Node && this.panel.contains(node); } private stepLink(label: string, index: number): HTMLAnchorElement { const link = createElement("a", { attributes: { href: ANCHOR_PREFIX + WALK_PANEL_ID }, text: label }); link.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); this.show(index, false); }); link.addEventListener("mouseenter", () => { this.cancel(); this.disarm = armLink(link); this.timer = setTimeout(() => { this.show(index, false); }, REFERENCE_CHAIN_MS); }); link.addEventListener("mouseleave", () => { this.cancel(); }); return link; } private stepRow(index: number): HTMLElement { const edges: (HTMLElement | string)[] = [String(index + 1) + OF_STEPS_LABEL + String(this.cells.length)]; if (index > 0) { edges.push(LIST_SEPARATOR, this.stepLink(PREVIOUS_STEP_LABEL, index - 1)); } if (index < this.cells.length - 1) { edges.push(LIST_SEPARATOR, this.stepLink(NEXT_STEP_LABEL, index + 1)); } return createElement("div", { children: [ createElement("span", { className: REFERENCE_LABEL_CLASS, text: STEP_LABEL + LABEL_SEPARATOR }), createElement("span", { children: edges, className: REFERENCE_EDGES_CLASS }), ], className: REFERENCE_RELATION_CLASS, }); } }