/* ============================================================================= * M02_DF_Diagram.ts * 단가산출 다이어그램 — 자유 배치(정함 점검 8 · 9 · 12 · 「상자 · 선 약속」) * 상자(입력 · 함수 · 출력)를 끌어 아무 자리에 놓음(on.move) · 판 끌기 · 확대 / 축소 · 「한눈에」 · 「줄 맞추기」 · * 상자 = 종류 · 이름 · 오른쪽 위 단위 · 색 · 왼쪽 받는 알약 · 오른쪽 내보내는 알약(알약에 지금 값) · * 선 = 굽은 선 · 내보내는 알약을 끌어 받는 알약에 놓으면 on.link(막히면 까닭 한 줄) · 고른 상자와 이어진 선만 진하게. * SheetLogic 다이어그램(M01_SL_Diagram)을 옮겨 고쳐 씀 — 그 파일에 기대지 않음. 그리기만(바꾸는 것은 부른 쪽이 Model 로). * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements"; import { portsIn, portsOut, type CfBox, type CfKind, type CfLogic, type CfPill, type CfPort, } from "./M02_DF_Model"; import "./M02_DF_Diagram.css"; const TEXT = { Title: ["다이어그램", "Diagram"], K_input: ["입력", "Input"], K_function: ["함수", "Function"], K_output: ["출력", "Output"], AddInput: ["+ 입력", "+ Input"], AddFunc: ["+ 함수", "+ Function"], AddOutput: ["+ 출력", "+ Output"], ZoomIn: ["확대", "Zoom in"], ZoomOut: ["축소", "Zoom out"], Fit: ["한눈에", "Fit all"], Arrange: ["줄 맞추기", "Line up"], Cancel: ["취소", "Cancel"], Connect: ["잇기", "Connect"], Disconnect: ["끊기", "Disconnect"], Remove: ["상자 지우기", "Remove box"], AskRemove: [ "「{v}」 상자를 지울까요? 이어진 선도 함께 끊어짐", "Remove box “{v}”? Its lines go too", ], PickTarget: ["「{v}」 을 받을 알약을 누르세요", "Tap the pill that receives “{v}”"], Empty: ["상자가 없음 — 「+ 입력」 으로 시작", "No boxes — start with “+ Input”"], NoIn: ["받는 알약이 없는 상자", "This box has no receiving pill"], SameBox: ["같은 상자 안에서는 이을 수 없음", "Can't connect inside the same box"], NotIn: ["받는 알약에 놓아야 이어짐", "Drop it on a receiving pill"], NoMatch: [ "같은 이름 받는 알약 없음 — 알약에 바로 놓으세요", "No pill with that name — drop on a pill", ], Taken: ["이미 이어진 알약 — 먼저 그 선을 끊으세요", "Pill already linked — disconnect first"], From: ["어디서", "From"], To: ["어디로", "To"], Value: ["지금 값", "Now"], PlusIn: [ "받는 자리 — 내보내는 알약을 여기에 놓으면 받는 알약이 생김", "Drop an output pill here to add an input", ], LineOf: ["선 · {a} → {b}", "Line · {a} → {b}"], Hint: [ "상자 머리를 끌어 옮김 · 빈 곳 끌어 판 옮김 · 휠로 확대", "Drag head to move · drag space to pan", ], } as const; const tt = (key: keyof typeof TEXT, fill: Record = {}): string => TEXT[key][currentLanguageIndex as 0 | 1].replace(/\{(\w)\}/g, (_m, k: string) => fill[k] ?? ""); /** 종류별 기본 색(상자마다 바꾼 색이 먼저) */ export const KIND_COLOR: Record = { input: "#2a6fb0", function: "#7a4fb5", output: "#c2610c", }; export interface DiagramOn { select(id: string | null): void; move(boxId: string, x: number, y: number): void; /** 잇기 — 막히면 까닭(쉬운 말) */ link(from: CfPort, to: CfPort): string | null | void; unlink(linkId: string): void; add(kind: CfKind, x: number, y: number): void; remove(boxId: string): void; } export interface DiagramOpts { /** 상자 id → 알약 이름 → 지금 값 */ values?: Record>; /** 고른 상자 id · 선 id */ selected?: string | null; } /* ── 알약 · 자리(DOM 없음) ─────────────────────────────────────────── */ /** 알약 — Model 의 받는 · 내보내는 알약 */ export function pillsOf(box: CfBox): { ins: CfPill[]; outs: CfPill[] } { return { ins: portsIn(box), outs: portsOut(box) }; } /** 떨군 자리 → 받을 끝 · 상자에 떨구면 같은 이름 알약(받는 알약이 하나면 그것) · 못 정하면 까닭 */ export function resolveDrop( lg: CfLogic, from: CfPort, target: { box: string; name: string | null; dir: "in" | "out" | null } | null, ): { to: CfPort } | { why: string } { if (!target) return { why: tt("NotIn") }; if (target.box === from.box) return { why: tt("SameBox") }; if (target.dir === "out") return { why: tt("NotIn") }; const box = lg.boxes.find((b) => b.id === target.box); // 함수 상자 몸통 · 「+」 받는 자리 — 보낸 알약 이름으로 받는 알약을 새로(Model 이 만들고 이름이 겹치면 「이름 (2)」) if (box?.kind === "function" && target.name === null) return { to: { box: box.id, name: from.name } }; const ins = box ? pillsOf(box).ins : []; if (!box || !ins.length) return { why: tt("NoIn") }; const name = target.name ?? ins.find((p) => p.name === from.name)?.name ?? (ins.length === 1 ? ins[0].name : null); if (!name) return { why: tt("NoMatch") }; if (lg.links.some((l) => l.to.box === box.id && l.to.name === name)) return { why: tt("Taken") }; return { to: { box: box.id, name } }; } /** 「줄 맞추기」 — 선을 따라 왼→오 차례(가장 긴 길) · 같은 차례는 위→아래 · 돌면 그대로 */ export function arrange( lg: CfLogic, gap = { x: 260, y: 130 }, ): Record { const depth = new Map(lg.boxes.map((b) => [b.id, 0])); for (let round = 0; round < lg.boxes.length; round++) { let moved = false; for (const l of lg.links) { const d = (depth.get(l.from.box) ?? 0) + 1; if (depth.has(l.to.box) && d > (depth.get(l.to.box) ?? 0)) { depth.set(l.to.box, d); moved = true; } } if (!moved) break; } const rows = new Map(); const out: Record = {}; for (const b of [...lg.boxes].sort((a, c) => a.y - c.y)) { const d = depth.get(b.id) ?? 0; const r = rows.get(d) ?? 0; rows.set(d, r + 1); out[b.id] = { x: 40 + d * gap.x, y: 40 + r * gap.y }; } return out; } /** 저절로 맞출 때 가장 작은 확대 — 알약 글이 너무 작아지지 않게(더 줄이기는 축소 단추로) */ export const FIT_MIN = 0.9; /** 모두 보이게 맞춤 — 상자 둘레 → 확대 · 판 자리 · FIT_MIN 아래로 안 내림(넘치면 왼쪽 위부터 보임) */ export function fitView( rects: { x: number; y: number; w: number; h: number }[], view: { w: number; h: number }, pad = 40, ): { zoom: number; panX: number; panY: number } { if (!rects.length) return { zoom: 1, panX: 0, panY: 0 }; const x0 = Math.min(...rects.map((r) => r.x)) - pad; const y0 = Math.min(...rects.map((r) => r.y)) - pad; const x1 = Math.max(...rects.map((r) => r.x + r.w)) + pad; const y1 = Math.max(...rects.map((r) => r.y + r.h)) + pad; const zoom = clampZoom(Math.max(FIT_MIN, Math.min(view.w / (x1 - x0), view.h / (y1 - y0), 1.5))); /** 남으면 가운데 · 넘치면 왼쪽(위) 끝부터 */ const place = (size: number, from: number, to: number): number => Math.max(0, (size - (to - from) * zoom) / 2) - from * zoom; return { zoom, panX: place(view.w, x0, x1), panY: place(view.h, y0, y1) }; } const clampZoom = (z: number): number => Math.min(2, Math.max(0.25, +z.toFixed(3))); /* ── 그리기 ─────────────────────────────────────────────────────── */ interface HostState { selected: string | null; linking: CfPort | null; pill: { box: string; name: string; dir: "in" | "out" } | null; note: string; zoom: number; panX: number; panY: number; fitted: boolean; watch?: ResizeObserver; } const states = new WeakMap(); const SVG = "http://www.w3.org/2000/svg"; /** 알약 값 글 — 수는 천 단위 쉼표 · 소수 셋째 자리까지(끝 0 지움) · 표는 「· ○줄」 · 글은 그대로 */ export const showValue = (v: unknown): string => { if (v === null || v === undefined || v === "") return ""; if (typeof v === "number") return Number.isFinite(v) ? v.toLocaleString("ko-KR", { maximumFractionDigits: 3 }) : ""; if (Array.isArray(v)) { const head = Array.isArray(v[0]) && v[0].every((c) => typeof c === "string") ? 1 : 0; return `· ${Math.max(0, v.length - head).toLocaleString("ko-KR")}줄`; } return String(v).slice(0, 12); }; export function renderDiagram( host: HTMLElement, lg: CfLogic, on: DiagramOn, opts: DiagramOpts = {}, ): void { const st: HostState = states.get(host) ?? { selected: null, linking: null, pill: null, note: "", zoom: 1, panX: 0, panY: 0, fitted: false, }; if (opts.selected !== undefined) st.selected = opts.selected; states.set(host, st); const values = opts.values ?? {}; const boxName = (id: string): string => lg.boxes.find((b) => b.id === id)?.name ?? ""; const redraw = (): void => renderDiagram(host, lg, on, { ...opts, selected: st.selected }); const select = (id: string | null): void => { st.selected = id; st.pill = null; st.note = ""; on.select(id); redraw(); }; const tryLink = (from: CfPort, target: Parameters[2]): void => { const got = resolveDrop(lg, from, target); st.linking = null; st.note = "why" in got ? got.why : on.link(from, got.to) || ""; redraw(); }; /* 판 · 세상(확대 · 옮김을 한 번에) */ const world = el("div", { className: "m02-dfdg__world" }); const svg = document.createElementNS(SVG, "svg"); svg.classList.add("m02-dfdg__lines"); world.append(svg); const view = el("div", { className: "m02-dfdg__view", attrs: { "aria-label": tt("Title") }, children: [world], }); const applyView = (): void => { world.style.transform = `translate(${st.panX}px, ${st.panY}px) scale(${st.zoom})`; zoomLabel.textContent = `${Math.round(st.zoom * 100)}%`; }; /** 화면 점 → 세상 자리 */ const toWorld = (cx: number, cy: number): { x: number; y: number } => { const r = view.getBoundingClientRect(); return { x: (cx - r.left - st.panX) / st.zoom, y: (cy - r.top - st.panY) / st.zoom }; }; /* 상자 */ const pillEl = (b: CfBox, p: CfPill, dir: "in" | "out"): HTMLElement => { const wired = dir === "out" || lg.links.some((l) => l.to.box === b.id && l.to.name === p.name); const chosen = st.pill?.box === b.id && st.pill.name === p.name && st.pill.dir === dir; const btn = el("button", { className: [ "m02-dfdg__pill", `is-${dir}`, wired ? "" : "is-empty", chosen ? "is-chosen" : "", st.linking && dir === "in" && b.id !== st.linking.box ? "is-target" : "", ].join(" "), attrs: { type: "button", "data-box": b.id, "data-name": p.name, "data-dir": dir, title: `${p.name} (${p.unit || "-"})`, }, children: [ el("span", { className: "m02-dfdg__pill-name", text: wired ? p.name : `? ${p.name}` }), el("span", { className: "m02-dfdg__pill-value", text: showValue(values[b.id]?.[p.name]) }), ], }); btn.addEventListener("click", (ev) => { ev.stopPropagation(); if (st.linking && dir === "in") return tryLink(st.linking, { box: b.id, name: p.name, dir }); st.pill = chosen ? null : { box: b.id, name: p.name, dir }; st.linking = null; st.note = ""; redraw(); }); if (dir === "out") btn.addEventListener("pointerdown", (ev) => dragLink(ev, { box: b.id, name: p.name })); return btn; }; /** 함수 상자 왼쪽 흐린 「+」 — 여기(또는 몸통)에 놓으면 받는 알약이 새로 생김 */ const plusSlot = (b: CfBox): HTMLElement => { const slot = el("button", { className: `m02-dfdg__pill is-in is-plus${st.linking && st.linking.box !== b.id ? " is-target" : ""}`, text: "+", attrs: { type: "button", "data-box": b.id, "data-dir": "in", "data-plus": "1", title: tt("PlusIn"), }, }); slot.addEventListener("click", (ev) => { ev.stopPropagation(); if (st.linking) return tryLink(st.linking, { box: b.id, name: null, dir: "in" }); st.note = tt("PlusIn"); redraw(); }); return slot; }; const boxEl = (b: CfBox): HTMLElement => { const { ins, outs } = pillsOf(b); const head = el("div", { className: "m02-dfdg__head", children: [ el("span", { className: "m02-dfdg__kind", text: tt(`K_${b.kind}` as keyof typeof TEXT) }), el("strong", { className: "m02-dfdg__name", text: b.name }), el("span", { className: "m02-dfdg__unit", text: b.unit || "-" }), ], }); const node = el("div", { className: `m02-dfdg__box is-${b.kind}${st.selected === b.id ? " is-selected" : ""}`, attrs: { "data-box": b.id, tabindex: "0", role: "button", "aria-label": `${tt(`K_${b.kind}` as keyof typeof TEXT)} · ${b.name}`, }, children: [ head, el("div", { className: "m02-dfdg__pills", children: [ el("div", { className: "m02-dfdg__ins", children: [ ...ins.map((p) => pillEl(b, p, "in")), ...(b.kind === "function" ? [plusSlot(b)] : []), ], }), el("div", { className: "m02-dfdg__outs", children: outs.map((p) => pillEl(b, p, "out")), }), ], }), ], }); node.style.left = `${b.x}px`; node.style.top = `${b.y}px`; node.style.setProperty("--cf-color", b.color || KIND_COLOR[b.kind]); node.addEventListener("click", (ev) => { ev.stopPropagation(); if (st.linking) return tryLink(st.linking, { box: b.id, name: null, dir: null }); select(st.selected === b.id ? null : b.id); }); node.addEventListener("keydown", (ev) => { if (ev.target !== node) return; if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); node.click(); } const step = ev.shiftKey ? 50 : 10; const d = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step], }[ev.key]; if (d && st.selected === b.id) { ev.preventDefault(); on.move(b.id, Math.round(b.x + d[0]), Math.round(b.y + d[1])); } }); head.addEventListener("pointerdown", (ev) => dragBox(ev, b, node)); return node; }; world.append(...lg.boxes.map(boxEl)); /* 위 줄 — 이름 · 더하기 · 확대 · 고른 것 */ const zoomLabel = el("span", { className: "m02-dfdg__zoom" }); const setZoom = (z: number, cx?: number, cy?: number): void => { const r = view.getBoundingClientRect(); const px = cx ?? r.left + r.width / 2; const py = cy ?? r.top + r.height / 2; const at = toWorld(px, py); st.zoom = clampZoom(z); st.panX = px - r.left - at.x * st.zoom; st.panY = py - r.top - at.y * st.zoom; applyView(); drawLines(); }; const fit = (): void => { const rects = lg.boxes.map((b) => { const n = world.querySelector(`.m02-dfdg__box[data-box="${CSS.escape(b.id)}"]`); return { x: b.x, y: b.y, w: n?.offsetWidth ?? 180, h: n?.offsetHeight ?? 80 }; }); const r = view.getBoundingClientRect(); Object.assign(st, fitView(rects, { w: r.width, h: r.height })); applyView(); drawLines(); }; const freeSpot = (): { x: number; y: number } => { const r = view.getBoundingClientRect(); const c = toWorld(r.left + r.width / 3, r.top + r.height / 3); let y = c.y; while (lg.boxes.some((b) => Math.abs(b.x - c.x) < 160 && Math.abs(b.y - y) < 90)) y += 100; return { x: Math.round(c.x), y: Math.round(y) }; }; /** 작은 단추 */ const sm = ( label: keyof typeof TEXT, onClick: () => void, variant: "ghost" | "filled" | "danger" = "ghost", ) => createButton({ label: tt(label), size: "sm", variant, onClick }); const addBtn = (kind: CfKind, label: keyof typeof TEXT): HTMLButtonElement => sm(label, () => { const at = freeSpot(); on.add(kind, at.x, at.y); }); const endName = (e: CfPort): string => `${boxName(e.box)} · ${e.name}`; const selLink = lg.links.find((l) => l.id === st.selected) ?? null; const selBox = lg.boxes.find((b) => b.id === st.selected) ?? null; const askRemove = async (b: CfBox): Promise => { if (await showConfirmDialog(tt("AskRemove", { v: b.name }), tt("Remove"))) on.remove(b.id); }; const tools: HTMLElement[] = []; if (st.linking) { tools.push( el("span", { text: tt("PickTarget", { v: st.linking.name }) }), sm("Cancel", () => ((st.linking = null), redraw())), ); } else if (st.pill) { const p = st.pill; const mine = (e: CfPort): boolean => e.box === p.box && e.name === p.name; const where = lg.links .filter((l) => mine(p.dir === "in" ? l.to : l.from)) .map((l) => endName(p.dir === "in" ? l.from : l.to)) .join(", "); const now = `${tt("Value")} ${showValue(values[p.box]?.[p.name]) || "—"}`; const dir = where ? `${tt(p.dir === "in" ? "From" : "To")} ${where}` : ""; tools.push(el("span", { text: [p.name, now, dir].filter(Boolean).join(" · ") })); if (p.dir === "out") tools.push( sm( "Connect", () => ((st.linking = { box: p.box, name: p.name }), (st.pill = null), redraw()), "filled", ), ); } else if (selLink) { const line = tt("LineOf", { a: endName(selLink.from), b: endName(selLink.to) }); tools.push( el("span", { text: line }), sm("Disconnect", () => on.unlink(selLink.id), "danger"), ); } else if (selBox) { tools.push(sm("Remove", () => void askRemove(selBox), "danger")); } const bar = el("div", { className: "m02-dfdg__bar", children: [ el("strong", { className: "m02-dfdg__title", text: tt("Title") }), addBtn("input", "AddInput"), addBtn("function", "AddFunc"), addBtn("output", "AddOutput"), el("span", { className: "m02-dfdg__gap" }), sm("ZoomOut", () => setZoom(st.zoom / 1.2)), zoomLabel, sm("ZoomIn", () => setZoom(st.zoom * 1.2)), sm("Fit", fit), sm("Arrange", () => { for (const [id, p] of Object.entries(arrange(lg))) on.move(id, p.x, p.y); }), ], }); const root = el("div", { className: `m02-dfdg${st.linking ? " is-linking" : ""}`, children: [ bar, el("div", { className: "m02-dfdg__tools", children: [ ...tools, el("span", { className: "m02-dfdg__note", text: st.note }), el("span", { className: "m02-dfdg__hint", text: tools.length ? "" : tt("Hint") }), ], }), lg.boxes.length ? view : el("p", { className: "m02-dfdg__hint", text: tt("Empty") }), ], }); root.addEventListener("keydown", (ev) => { if (ev.key === "Escape" && (st.linking || st.pill)) { st.linking = null; st.pill = null; redraw(); } if (ev.key !== "Delete" || (ev.target as HTMLElement).closest("input, textarea")) return; if (selLink) on.unlink(selLink.id); else if (selBox) void askRemove(selBox); }); view.addEventListener("click", (ev) => { if (ev.target === view || ev.target === world) select(null); }); view.addEventListener( "wheel", (ev) => { ev.preventDefault(); setZoom(st.zoom * (ev.deltaY < 0 ? 1.1 : 1 / 1.1), ev.clientX, ev.clientY); }, { passive: false }, ); view.addEventListener("pointerdown", (ev) => { if (ev.target !== view && ev.target !== world && ev.target !== svg) return; dragPan(ev); }); host.replaceChildren(root); applyView(); /* 선 — 알약 자리를 재서 세상 좌표로 */ function drawLines(): void { svg.replaceChildren(); const at = (end: CfPort, dir: "in" | "out"): { x: number; y: number } | null => { const pill = world.querySelector( `.m02-dfdg__pill[data-box="${CSS.escape(end.box)}"][data-name="${CSS.escape(end.name)}"][data-dir="${dir}"]`, ); if (!pill) return null; const r = pill.getBoundingClientRect(); return toWorld(dir === "out" ? r.right : r.left, r.top + r.height / 2); }; for (const l of lg.links) { const a = at(l.from, "out"); const b = at(l.to, "in"); if (!a || !b) continue; const dx = Math.max(40, Math.abs(b.x - a.x) / 2); const d = `M${a.x},${a.y} C${a.x + dx},${a.y} ${b.x - dx},${b.y} ${b.x},${b.y}`; const strong = st.selected === l.id || (selBox !== null && (l.from.box === selBox.id || l.to.box === selBox.id)); const g = document.createElementNS(SVG, "g"); g.setAttribute( "class", `m02-dfdg__link${strong ? " is-strong" : ""}${st.selected !== null && !strong ? " is-dim" : ""}`, ); g.setAttribute("data-link", l.id); g.setAttribute("data-ends", [a.x, a.y, b.x, b.y].map((n) => n.toFixed(1)).join(",")); for (const cls of ["m02-dfdg__hit", "m02-dfdg__wire"]) { const p = document.createElementNS(SVG, "path"); p.setAttribute("d", d); p.setAttribute("class", cls); g.append(p); } g.addEventListener("click", (ev) => { ev.stopPropagation(); select(st.selected === l.id ? null : l.id); }); svg.append(g); } } const firstFit = (): void => { if (st.fitted || !lg.boxes.length || !view.clientWidth) return drawLines(); st.fitted = true; fit(); }; st.watch?.disconnect(); st.watch = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(firstFit); st.watch?.observe(view); if (view.isConnected) firstFit(); /* 끌기 셋 — 상자 · 판 · 선 */ function follow( ev: PointerEvent, move: (m: PointerEvent) => void, end: (u: PointerEvent, moved: boolean) => void, ): void { if (ev.button !== 0) return; const x0 = ev.clientX; const y0 = ev.clientY; let moved = false; const mv = (m: PointerEvent): void => { if (!moved && Math.hypot(m.clientX - x0, m.clientY - y0) < 5) return; moved = true; move(m); }; const up = (u: PointerEvent): void => { window.removeEventListener("pointermove", mv); window.removeEventListener("pointerup", up); if (moved) { // 끈 뒤 바로 따라오는 click 만 삼킴 — 안 오면 곧 풀어 다음 누름은 그대로 const swallow = (c: MouseEvent): void => c.stopPropagation(); window.addEventListener("click", swallow, { capture: true, once: true }); setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0); } end(u, moved); }; window.addEventListener("pointermove", mv); window.addEventListener("pointerup", up); } function dragBox(ev: PointerEvent, b: CfBox, node: HTMLElement): void { const start = toWorld(ev.clientX, ev.clientY); let x = b.x; let y = b.y; follow( ev, (m) => { const now = toWorld(m.clientX, m.clientY); x = Math.round(b.x + now.x - start.x); y = Math.round(b.y + now.y - start.y); node.style.left = `${x}px`; node.style.top = `${y}px`; drawLines(); }, (_u, moved) => { if (moved) on.move(b.id, x, y); }, ); } function dragPan(ev: PointerEvent): void { const p0 = { x: st.panX, y: st.panY }; follow( ev, (m) => { st.panX = p0.x + m.clientX - ev.clientX; st.panY = p0.y + m.clientY - ev.clientY; applyView(); }, () => undefined, ); } function dragLink(ev: PointerEvent, from: CfPort): void { ev.stopPropagation(); const pill = ev.currentTarget as HTMLElement; const r = pill.getBoundingClientRect(); const a = toWorld(r.right, r.top + r.height / 2); let line: SVGPathElement | null = null; follow( ev, (m) => { const b = toWorld(m.clientX, m.clientY); if (!line) { line = document.createElementNS(SVG, "path"); line.setAttribute("class", "m02-dfdg__drag"); svg.append(line); } line.setAttribute("d", `M${a.x},${a.y} L${b.x},${b.y}`); }, (u, moved) => { if (!moved) return; line?.remove(); const hit = document.elementFromPoint(u.clientX, u.clientY) as HTMLElement | null; const p = hit?.closest(".m02-dfdg__pill"); const bx = hit?.closest(".m02-dfdg__box"); tryLink( from, p ? { box: p.dataset.box ?? "", name: p.dataset.name ?? null, dir: (p.dataset.dir as "in" | "out") ?? null, } : bx ? { box: bx.dataset.box ?? "", name: null, dir: null } : null, ); }, ); } }