/* ============================================================================= * M01_SL_Diagram.ts * 시트 로직 — 위 가로 다이어그램(정함 8 · 제안서 「화면 배치」 · 「상자의 입출력도 알약으로」) * 단계 다섯 칸(입력값 · 표 · 계산 · 항목 · 결과) · 단계마다 상자를 세로로 쌓음(자리는 저절로) · * 상자 왼쪽 입력 알약(파랑 · 빈 자리는 회색 「? 이름」) · 오른쪽 출력 알약(주황) · 선 = SVG. * 출력 알약을 끌어 입력 알약에 놓으면 on.connect · 선을 골라 Delete / 「끊기」 → on.disconnect · * 「+」 → 종류 → on.add · 상자를 골라 Delete → 물음 → on.remove. 키보드: Tab · Enter · 「잇기」. * 그리기만 — 시트 로직을 바꾸지 않음(바꾸는 것은 부른 쪽이 Model 로). * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements"; import type { SheetLogic, SlEdge, SlEnd, SlKind, SlNode } from "./M01_SL_Model"; import "./M01_SL_Diagram.css"; const TEXT = { Stage0: ["입력값", "Inputs"], Stage1: ["표", "Tables"], Stage2: ["계산", "Calculations"], Stage3: ["항목", "Items"], Stage4: ["결과", "Result"], K_input: ["입력값", "Input"], K_const: ["고정값", "Fixed value"], K_table: ["표", "Table"], K_calc: ["계산", "Calculation"], K_item: ["항목", "Item"], K_logic: ["다른 로직", "Other logic"], K_result: ["결과", "Result"], Add: ["+ 상자", "+ Box"], AddTitle: ["더할 상자 종류", "Kind of box to add"], Cancel: ["취소", "Cancel"], Connect: ["잇기", "Connect"], Disconnect: ["끊기", "Disconnect"], Remove: ["상자 지우기", "Remove box"], AskRemove: [ "「{v}」 상자를 지울까요? 이어진 선도 함께 끊어짐", "Remove box “{v}”? Its lines go too", ], PickTarget: ["「{v}」 을 받을 입력 알약을 누르세요", "Tap the input pill that receives “{v}”"], Empty: ["상자가 없음 — 「+ 상자」 로 더함", "No boxes — add one with “+ Box”"], NoOut: ["내보내는 알약이 없음", "No output pills"], NoIn: ["받는 알약이 없음", "No input pills"], SameBox: ["같은 상자 안에서는 이을 수 없음", "Can't connect inside the same box"], NotIn: ["입력 알약에 놓아야 이어짐", "Drop it on an input pill"], NoMatch: [ "이름이 같은 입력 알약이 없음 — 받을 알약에 바로 놓으세요", "No input pill with the same name — drop on the pill itself", ], Taken: [ "이미 이어진 입력 알약 — 먼저 그 선을 끊으세요", "That input already has a line — disconnect it first", ], From: ["어디서", "From"], To: ["어디로", "To"], Value: ["지금 값", "Now"], LineOf: ["선 · {a} → {b}", "Line · {a} → {b}"], } as const; const td = (key: keyof typeof TEXT, fill: Record = {}): string => TEXT[key][currentLanguageIndex as 0 | 1].replace(/\{(\w)\}/g, (_m, k: string) => fill[k] ?? ""); /** 종류 → 기본 단계(고정값 · 다른 로직은 앞 칸) */ export const STAGE_OF: Record = { input: 0, const: 0, logic: 0, table: 1, calc: 2, item: 3, result: 4, }; const KINDS: SlKind[] = ["input", "const", "table", "calc", "item", "logic", "result"]; const kindLabel = (k: SlKind): string => td(`K_${k}` as keyof typeof TEXT); export interface DiagramOn { select(id: string | null): void; /** 잇기 — 막히면 까닭(쉬운 말)을 돌려주면 띠에 한 줄로 보임 */ connect(from: SlEnd, to: SlEnd): string | null | void; disconnect(edgeId: string): void; add(kind: SlKind, stage: number): void; remove(nodeId: string): void; } export interface DiagramOpts { /** 상자 id → 알약 이름 → 지금 값 */ values?: Record>; /** 고른 상자 id · 선 id */ selected?: string | null; } /* ── 자리 정하기(DOM 없음) ─────────────────────────────────────────── */ /** 단계 칸마다 상자 차례 — node.stage(없으면 종류 기본) · 0~4 로 자름 · 같은 칸은 sl 차례 */ export function layout(nodes: SlNode[]): SlNode[][] { const cols: SlNode[][] = [[], [], [], [], []]; for (const n of nodes) { const s = Number.isInteger(n.stage) ? n.stage : STAGE_OF[n.kind]; cols[Math.min(4, Math.max(0, s ?? 0))].push(n); } return cols; } /** 떨군 자리 → 받을 끝 · 상자에 떨구면 이름 같은 입력 알약 · 못 정하면 까닭 */ export function resolveDrop( sl: SheetLogic, from: SlEnd, target: { node: string; pill: string | null; dir: "in" | "out" | null } | null, ): { to: SlEnd } | { why: string } { if (!target) return { why: td("NotIn") }; if (target.node === from.node) return { why: td("SameBox") }; if (target.dir === "out") return { why: td("NotIn") }; const node = sl.nodes.find((n) => n.id === target.node); if (!node?.pillsIn.length) return { why: td("NoIn") }; const name = target.pill ?? node.pillsIn.find((p) => p.name === from.pill)?.name; if (!name) return { why: td("NoMatch") }; if (sl.edges.some((e) => e.to.node === node.id && e.to.pill === name)) return { why: td("Taken") }; return { to: { node: node.id, pill: name } }; } /* ── 그리기 ─────────────────────────────────────────────────────── */ interface HostState { selected: string | null; /** 잇기 중 — 출력 알약 */ linking: SlEnd | null; /** 고른 알약(정보 줄) */ pill: { node: string; pill: string; dir: "in" | "out" } | null; note: string; scroll: number; /** 선 다시 그리기 — 띠 크기 바뀜 */ watch?: ResizeObserver; } const states = new WeakMap(); const showValue = (v: unknown): string => { if (v === null || v === undefined || v === "") return ""; if (typeof v === "number") return Number.isInteger(v) ? v.toLocaleString() : String(+v.toFixed(4)); return String(v).slice(0, 12); }; const edgeTouches = (e: SlEdge, node: string): boolean => e.from.node === node || e.to.node === node; export function renderDiagram( host: HTMLElement, sl: SheetLogic, on: DiagramOn, opts: DiagramOpts = {}, ): void { const old = states.get(host); const st: HostState = old ?? { selected: null, linking: null, pill: null, note: "", scroll: 0 }; if (opts.selected !== undefined) st.selected = opts.selected; states.set(host, st); const values = opts.values ?? {}; const nodeName = (id: string): string => sl.nodes.find((n) => n.id === id)?.name ?? ""; const redraw = (): void => renderDiagram(host, sl, on, { ...opts, selected: st.selected }); const select = (id: string | null): void => { st.selected = id; st.pill = null; st.note = ""; on.select(id); redraw(); }; const tryConnect = (from: SlEnd, target: Parameters[2]): void => { const got = resolveDrop(sl, from, target); st.linking = null; if ("why" in got) st.note = got.why; else st.note = on.connect(from, got.to) || ""; redraw(); }; /* 상자 */ const pillEl = (n: SlNode, name: string, dir: "in" | "out", unit?: string): HTMLElement => { const wired = dir === "out" || sl.edges.some((e) => e.to.node === n.id && e.to.pill === name); const linking = st.linking; const chosen = st.pill?.node === n.id && st.pill.pill === name && st.pill.dir === dir; const b = el("button", { className: [ "m01-sldg__pill", `is-${dir}`, wired ? "" : "is-empty", chosen ? "is-chosen" : "", linking && dir === "in" && n.id !== linking.node ? "is-target" : "", ].join(" "), attrs: { type: "button", "data-node": n.id, "data-pill": name, "data-dir": dir, title: unit ? `${name} (${unit})` : name, }, children: [ el("span", { className: "m01-sldg__pill-name", text: wired ? name : `? ${name}` }), el("span", { className: "m01-sldg__pill-value", text: showValue(values[n.id]?.[name]) }), ], }); b.addEventListener("click", (ev) => { ev.stopPropagation(); if (st.linking && dir === "in") return tryConnect(st.linking, { node: n.id, pill: name, dir }); st.pill = chosen ? null : { node: n.id, pill: name, dir }; st.linking = null; st.note = ""; redraw(); }); if (dir === "out") b.addEventListener("pointerdown", (ev) => startDrag(ev, { node: n.id, pill: name })); return b; }; const boxEl = (n: SlNode): HTMLElement => { const box = el("div", { className: ["m01-sldg__box", `is-${n.kind}`, st.selected === n.id ? "is-selected" : ""].join( " ", ), attrs: { "data-node": n.id, tabindex: "0", role: "button", "aria-label": `${kindLabel(n.kind)} · ${n.name}`, }, children: [ el("div", { className: "m01-sldg__box-head", children: [ el("span", { className: "m01-sldg__kind", text: kindLabel(n.kind) }), el("strong", { className: "m01-sldg__name", text: n.name }), ], }), el("div", { className: "m01-sldg__pills", children: [ el("div", { className: "m01-sldg__ins", children: n.pillsIn.map((p) => pillEl(n, p.name, "in", p.unit)), }), el("div", { className: "m01-sldg__outs", children: n.pillsOut.map((p) => pillEl(n, p.name, "out", p.unit)), }), ], }), ], }); box.addEventListener("click", () => { if (st.linking) return tryConnect(st.linking, { node: n.id, pill: null, dir: null }); select(st.selected === n.id ? null : n.id); }); box.addEventListener("keydown", (ev) => { if (ev.target !== box) return; if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); box.click(); } }); return box; }; const cols = layout(sl.nodes); const inner = el("div", { className: "m01-sldg__inner", children: cols.map((list, i) => el("div", { className: "m01-sldg__col", children: [ el("div", { className: "m01-sldg__col-head", text: td(`Stage${i}` as keyof typeof TEXT), }), ...list.map(boxEl), ], }), ), }); const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.classList.add("m01-sldg__lines"); inner.prepend(svg); inner.addEventListener("click", (ev) => { if (ev.target === inner || (ev.target as HTMLElement).classList.contains("m01-sldg__col")) { st.linking = null; select(null); } }); /* 위 줄 — 더하기 · 고른 것 · 할 일 */ const addMenu = el("div", { className: "m01-sldg__menu", attrs: { hidden: "", role: "menu" } }); addMenu.append( el("span", { className: "m01-sldg__muted", text: td("AddTitle") }), ...KINDS.map((k) => createButton({ label: kindLabel(k), size: "sm", variant: "ghost", onClick: () => { addMenu.hidden = true; on.add(k, STAGE_OF[k]); }, }), ), ); const addBtn = createButton({ label: td("Add"), size: "sm", onClick: () => (addMenu.hidden = !addMenu.hidden), }); const selEdge = sl.edges.find((e) => e.id === st.selected) ?? null; const selNode = sl.nodes.find((n) => n.id === st.selected) ?? null; const tools: HTMLElement[] = []; if (st.linking) { tools.push( el("span", { text: td("PickTarget", { v: st.linking.pill }) }), createButton({ label: td("Cancel"), size: "sm", variant: "ghost", onClick: () => ((st.linking = null), redraw()), }), ); } else if (st.pill) { const p = st.pill; const linked = sl.edges.filter((e) => p.dir === "in" ? e.to.node === p.node && e.to.pill === p.pill : e.from.node === p.node && e.from.pill === p.pill, ); const where = linked .map((e) => p.dir === "in" ? `${nodeName(e.from.node)} · ${e.from.pill}` : `${nodeName(e.to.node)} · ${e.to.pill}`, ) .join(", "); tools.push( el("span", { text: [ `${p.pill}`, `${td("Value")} ${showValue(values[p.node]?.[p.pill]) || "—"}`, where ? `${td(p.dir === "in" ? "From" : "To")} ${where}` : "", ] .filter(Boolean) .join(" · "), }), ); if (p.dir === "out") tools.push( createButton({ label: td("Connect"), size: "sm", onClick: () => { st.linking = { node: p.node, pill: p.pill }; st.pill = null; redraw(); }, }), ); } else if (selEdge) { tools.push( el("span", { text: td("LineOf", { a: `${nodeName(selEdge.from.node)} · ${selEdge.from.pill}`, b: `${nodeName(selEdge.to.node)} · ${selEdge.to.pill}`, }), }), createButton({ label: td("Disconnect"), size: "sm", variant: "danger", onClick: () => on.disconnect(selEdge.id), }), ); } else if (selNode) { tools.push( createButton({ label: td("Remove"), size: "sm", variant: "danger", onClick: () => void askRemove(selNode), }), ); } const askRemove = async (n: SlNode): Promise => { if (await showConfirmDialog(td("AskRemove", { v: n.name }), td("Remove"))) on.remove(n.id); }; const band = el("div", { className: "m01-sldg__band", children: [inner] }); const root = el("div", { className: `m01-sldg${st.linking ? " is-linking" : ""}`, attrs: { tabindex: "-1" }, children: [ el("div", { className: "m01-sldg__bar", children: [addBtn, ...tools, el("span", { className: "m01-sldg__note", text: st.note })], }), addMenu, sl.nodes.length ? band : el("p", { className: "m01-sldg__muted", text: td("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 (selEdge) on.disconnect(selEdge.id); else if (selNode) void askRemove(selNode); }); band.addEventListener("scroll", () => (st.scroll = band.scrollLeft)); host.replaceChildren(root); band.scrollLeft = st.scroll; /* 선 — 붙인 뒤 알약 자리를 재서 그림 */ const drawLines = (): void => { const box = inner.getBoundingClientRect(); svg.setAttribute("width", String(inner.scrollWidth)); svg.setAttribute("height", String(inner.scrollHeight)); svg.replaceChildren(); const at = (end: SlEnd, dir: "in" | "out"): { x: number; y: number } | null => { const pill = inner.querySelector( `.m01-sldg__pill[data-node="${CSS.escape(end.node)}"][data-pill="${CSS.escape(end.pill)}"][data-dir="${dir}"]`, ); if (!pill) return null; const r = pill.getBoundingClientRect(); return { x: (dir === "out" ? r.right : r.left) - box.left, y: r.top + r.height / 2 - box.top, }; }; for (const e of sl.edges) { const a = at(e.from, "out"); const b = at(e.to, "in"); if (!a || !b) continue; const dx = Math.max(30, 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 === e.id || (selNode !== null && edgeTouches(e, selNode.id)); const dim = st.selected !== null && !strong; const g = document.createElementNS("http://www.w3.org/2000/svg", "g"); g.setAttribute("class", `m01-sldg__edge${strong ? " is-strong" : ""}${dim ? " is-dim" : ""}`); g.setAttribute("data-edge", e.id); g.setAttribute("data-x1", a.x.toFixed(1)); g.setAttribute("data-y1", a.y.toFixed(1)); g.setAttribute("data-x2", b.x.toFixed(1)); g.setAttribute("data-y2", b.y.toFixed(1)); for (const cls of ["m01-sldg__hit", "m01-sldg__wire"]) { const p = document.createElementNS("http://www.w3.org/2000/svg", "path"); p.setAttribute("d", d); p.setAttribute("class", cls); g.append(p); } g.addEventListener("click", (ev) => { ev.stopPropagation(); select(st.selected === e.id ? null : e.id); }); svg.append(g); } }; // 붙어 있으면 바로 · 나중에 붙거나 크기가 바뀌면 다시(띠 밀기 · 창 크기) st.watch?.disconnect(); st.watch = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(drawLines); st.watch?.observe(inner); if (inner.isConnected) drawLines(); /* 끌어 잇기 */ function startDrag(ev: PointerEvent, from: SlEnd): void { if (ev.button !== 0) return; const pill = ev.currentTarget as HTMLElement; const box = inner.getBoundingClientRect(); const r = pill.getBoundingClientRect(); const x0 = r.right - box.left; const y0 = r.top + r.height / 2 - box.top; let line: SVGPathElement | null = null; const move = (m: PointerEvent): void => { const x = m.clientX - box.left; const y = m.clientY - box.top; if (!line && Math.hypot(x - x0, y - y0) < 6) return; if (!line) { line = document.createElementNS("http://www.w3.org/2000/svg", "path"); line.setAttribute("class", "m01-sldg__drag"); svg.append(line); } line.setAttribute("d", `M${x0},${y0} L${x},${y}`); }; const up = (u: PointerEvent): void => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); if (!line) return; // 끌지 않음 = 누름(click 이 처리) line.remove(); const hit = document.elementFromPoint(u.clientX, u.clientY) as HTMLElement | null; const pillHit = hit?.closest(".m01-sldg__pill"); const boxHit = hit?.closest(".m01-sldg__box"); const target = pillHit ? { node: pillHit.dataset.node ?? "", pill: pillHit.dataset.pill ?? null, dir: (pillHit.dataset.dir as "in" | "out") ?? null, } : boxHit ? { node: boxHit.dataset.node ?? "", pill: null, dir: null } : null; // 끈 뒤 바로 따라오는 click 만 삼킴 — 안 오면 곧 풀어 다음 누름은 그대로 const swallow = (c: MouseEvent): void => c.stopPropagation(); window.addEventListener("click", swallow, { capture: true, once: true }); setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0); tryConnect(from, target); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); } }