import { CELL_TEXT_SEPARATOR, GHOST_DECAY_PROPERTY, GHOST_LOOP_PAUSE_MS, GHOST_REFERENCE_CELLS, GHOST_STEP_MS, GHOST_TAIL_SHARE, SNIPPET_RADIUS, WALK_CELL_SELECTOR, WALK_GHOST_CLASS, WALK_STATUS_CLASS, WALK_TRAIL_CLASS, } from "#configuration/constants/anatomy.constants"; import { CODE_LINE_ATTRIBUTE, CODE_LINE_MARKED_CLASS, LINE_BREAK } from "#configuration/constants/syntax.constants"; import { DEPTH_LABEL, DEPTH_RELATION, LOCATION_RELATION, SEVERITY_RELATION, WALK_HINT, } from "#configuration/strings/walk.strings"; import { MILLISECONDS, REFERENCE_SNIPPET_CLASS } from "#configuration/constants/reference.constants"; import { languageOf, nodeHref } from "#domain/converters/source.converter"; import { loadSource, sourceNameOf } from "#core/loaders/walk.loader"; import { CHAPTER_FACE } from "#configuration/constants/vocabulary.constants"; import type { Disposer } from "#types/base.types"; import { FACE_SEPARATOR } from "@govlab/constants"; import { LINE_MARK } from "#configuration/strings/folder.strings"; import type { ReferenceRecord } from "#types/reference.types"; import type { WalkCellView } from "#types/anatomy.types"; import { createElement } from "#core/factories/element.factory"; import { renderSyntax } from "#presentation/renderers/syntax.renderer"; const TRANSITION_END = "transitionend"; const locationOf = function locationOf(cell: WalkCellView): string { return cell.file === "" || cell.line === null ? DEPTH_LABEL + String(cell.depth) : cell.file + LINE_MARK + String(cell.line); }; export const cellText = function cellText(cell: WalkCellView): string { return [locationOf(cell), cell.label, cell.text].join(CELL_TEXT_SEPARATOR); }; const fileRef = function fileRef(cell: WalkCellView): string | null { if (cell.file === "") { return null; } return CHAPTER_FACE + FACE_SEPARATOR + nodeHref(cell.file, cell.line); }; const snippetOf = function snippetOf(text: string, line: number, language: string): HTMLElement { const lines = text.split(LINE_BREAK); const first = Math.max(1, line - SNIPPET_RADIUS); const last = Math.min(lines.length, line + SNIPPET_RADIUS); const code = renderSyntax(lines.slice(first - 1, last).join(LINE_BREAK), language); code.querySelector(`[${CODE_LINE_ATTRIBUTE}="${String(line - first + 1)}"]`)?.classList.add(CODE_LINE_MARKED_CLASS); return createElement("pre", { children: [code], className: REFERENCE_SNIPPET_CLASS }); }; export const loadSnippet = async function loadSnippet(cell: WalkCellView): Promise { const name = cell.line === null ? null : sourceNameOf(cell.file); const text = name === null ? null : await loadSource(name); return text === null || cell.line === null ? null : snippetOf(text, cell.line, languageOf(cell.file)); }; export const cellRecord = function cellRecord(cell: WalkCellView): ReferenceRecord { const name = cell.name.length > 0 ? cell.name : cell.label; return { code: cell.state, kind: cell.label, layer: null, name, relations: [ { edges: [{ label: locationOf(cell), ref: fileRef(cell) }], relation: LOCATION_RELATION }, { edges: [{ label: String(cell.depth), ref: null }], relation: DEPTH_RELATION }, ...(cell.severity === null ? [] : [{ edges: [{ label: cell.severity, ref: null }], relation: SEVERITY_RELATION }]), ], summary: cell.text === name ? null : cell.text, }; }; export const createStatus = function createStatus(): HTMLElement { return createElement("p", { className: WALK_STATUS_CLASS, text: WALK_HINT }); }; const release = function release(event: Event): void { if (event.target instanceof Element) { event.target.classList.remove(WALK_TRAIL_CLASS); } }; const advanceGhost = function advanceGhost(polygons: readonly Element[], head: number): number { const previous = polygons[head]; if (previous !== undefined) { previous.classList.remove(WALK_GHOST_CLASS); previous.classList.add(WALK_TRAIL_CLASS); } const next = head + 1; const current = polygons[next]; if (current === undefined) { return -1; } current.classList.remove(WALK_TRAIL_CLASS); current.classList.add(WALK_GHOST_CLASS); return next; }; export const ghostPace = function ghostPace(cells: number): { readonly decay: number; readonly step: number; readonly stride: number; } { const step = Math.max(GHOST_STEP_MS, (GHOST_STEP_MS * GHOST_REFERENCE_CELLS) / Math.max(cells, 1)); const stride = Math.max(1, Math.round(cells / GHOST_REFERENCE_CELLS)); return { decay: Math.round((step * Math.max(1, cells / GHOST_TAIL_SHARE)) / stride), step, stride }; }; const stepGhost = function stepGhost(polygons: readonly Element[], head: number, stride: number): number { let next = head; for (let count = 0; count < stride && next !== -1; count += 1) { next = advanceGhost(polygons, next); } return next; }; export const runGhost = function runGhost(element: HTMLElement): Disposer { const polygons = [...element.querySelectorAll(WALK_CELL_SELECTOR)]; const pace = ghostPace(polygons.length); element.style.setProperty(GHOST_DECAY_PROPERTY, String(pace.decay) + MILLISECONDS); let head = -1; let timer: ReturnType | null = null; let visible = false; const tick = function tick(): void { timer = null; if (!visible) { return; } head = head === -1 ? advanceGhost(polygons, head) : stepGhost(polygons, head, pace.stride); timer = setTimeout(tick, head === -1 ? GHOST_LOOP_PAUSE_MS : pace.step); }; element.addEventListener(TRANSITION_END, release); const observer = new IntersectionObserver((entries) => { visible = entries.some((entry) => entry.isIntersecting); if (visible && timer === null) { tick(); } }); const track = observer.observe.bind(observer); track(element); return () => { observer.disconnect(); element.removeEventListener(TRANSITION_END, release); if (timer !== null) { clearTimeout(timer); } for (const polygon of polygons) { polygon.classList.remove(WALK_GHOST_CLASS, WALK_TRAIL_CLASS); } }; };