/* ============================================================================= * M02_DF_Diagram.ts * 단가산출 흐름도 — 다이어그램(흐름도 정함 7 · 8 · 13 · 1판 「상자 · 선 약속」) * 자유 배치 상자(입력 · 함수 · 출력) · 굽은 선 + 가운데 선 규칙 이름표 · 흐름 길(온 길 · 가는 길) 칠하기 · * 끊긴 곳 표시 · 알약 · 선 · 상자 고르기. 마우스 — 판 옮기기 = 가운데 단추 끌기(또는 Space + 왼쪽 끌기) · * 고르기 = 왼쪽 누름 · 상자 옮기기 = 상자 머리 왼쪽 끌기 · 휠 = 확대 · 글자 선택 막음. * 그리기만(바꾸는 것은 부른 쪽이 Model 로) · 순수 부분은 `M02_DF_Diagram_Parts.ts`. * ========================================================================== */ import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements"; import type { CfBox, CfKind, CfLogic, CfPill, CfPort } from "./M02_DF_Model"; import { arrange, clampZoom, fitView, KIND_COLOR, linkLabel, midOf, pillsOf, resolveDrop, showValue, traceOf, tt, type DfPillSel, type DiagramOn, type DiagramOpts, type TEXT, } from "./M02_DF_Diagram_Parts"; import "./M02_DF_Diagram.css"; export * from "./M02_DF_Diagram_Parts"; interface HostState { selected: string | null; linking: CfPort | null; pill: DfPillSel | 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"; /** Space 를 누르고 있음 — 터치패드용 판 옮기기 */ let spaceDown = false; if (typeof window !== "undefined") { window.addEventListener("keydown", (e) => { if (e.code === "Space" && !(e.target as HTMLElement)?.closest?.("input, textarea, select")) spaceDown = true; }); window.addEventListener("keyup", (e) => e.code === "Space" && (spaceDown = false)); window.addEventListener("blur", () => (spaceDown = false)); } 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; if (opts.selectedPill !== undefined) st.pill = opts.selectedPill; states.set(host, st); const values = opts.values ?? {}; const labels = opts.labels ?? {}; const breaks = opts.breaks ?? []; const boxName = (id: string): string => lg.boxes.find((b) => b.id === id)?.name ?? ""; const redraw = (): void => renderDiagram(host, lg, on, { ...opts, selected: st.selected, selectedPill: st.pill }); const select = (id: string | null, pill?: DfPillSel, note = ""): void => { st.selected = pill ? null : id; st.pill = pill ?? null; st.note = note; on.select(id, pill); 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 markOf = (pick: Parameters[1]): string => { const m = traceOf(opts.trace, pick); return m ? ` is-${m}` : ""; }; const warn = (why: string, onClick: () => void): HTMLElement => { const b = el("button", { className: "m02-dfdg__warn", text: "!", attrs: { type: "button", title: why, "aria-label": `${tt("Break")} · ${why}` }, }); b.addEventListener("pointerdown", (ev) => ev.stopPropagation()); b.addEventListener("click", (ev) => { ev.stopPropagation(); onClick(); }); return b; }; /* 판 · 세상(확대 · 옮김을 한 번에) */ 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 zoomLabel = el("span", { className: "m02-dfdg__zoom" }); 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.port === p.name && st.pill.dir === dir; const broken = breaks.find((k) => !k.link && k.box === b.id && k.port === p.name); const sel: DfPillSel = { box: b.id, port: p.name, dir }; const btn = el("button", { className: [ "m02-dfdg__pill", `is-${dir}`, wired ? "" : "is-empty", chosen ? "is-chosen" : "", broken ? "is-broken" : "", st.linking && dir === "in" && b.id !== st.linking.box ? "is-target" : "", ].join(" ") + markOf((s) => s.some((x) => x.box === b.id && x.port === p.name)), attrs: { type: "button", "data-box": b.id, "data-name": p.name, "data-dir": dir, title: `${p.name} (${p.unit || "-"})`, }, children: [ ...(broken ? [el("span", { className: "m02-dfdg__pill-warn", text: "!" })] : []), 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.linking = null; if (chosen) select(null); else select(b.id, sel, broken?.text ?? ""); }); 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 kind = tt(`K_${b.kind}` as keyof typeof TEXT); const broken = breaks.filter((k) => !k.link && !k.port && k.box === b.id); const head = el("div", { className: "m02-dfdg__head", children: [ el("span", { className: "m02-dfdg__kind", text: kind }), 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" : ""}` + (st.selected === b.id ? "" : markOf((s) => s.some((x) => x.box === b.id))), attrs: { "data-box": b.id, tabindex: "0", role: "button", "aria-label": `${kind} · ${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")), }), ], }), ...(broken.length ? [ warn(broken.map((k) => k.text).join(" · "), () => select(b.id, undefined, broken.map((k) => k.text).join(" · ")), ), ] : []), ], }); 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.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) => { if (ev.button === 0 && !spaceDown) dragBox(ev, b, node); }); return node; }; world.append(...lg.boxes.map(boxEl)); /* 위 줄 — 이름 · 더하기 · 확대 · 고른 것 */ 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.port; 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.port]) || "—"}`; const dir = where ? `${tt(p.dir === "in" ? "From" : "To")} ${where}` : ""; tools.push(el("span", { text: [p.port, now, dir].filter(Boolean).join(" · ") })); if (p.dir === "out") tools.push( sm( "Connect", () => ((st.linking = { box: p.box, name: p.port }), (st.pill = null), redraw()), "filled", ), ); } else if (selLink) { const rule = labels[selLink.id] ?? linkLabel(selLink); const line = tt("LineOf", { a: endName(selLink.from), b: endName(selLink.to) }); tools.push( el("span", { text: rule ? `${line} · ${rule}` : line }), sm("Disconnect", () => on.unlink(selLink.id), "danger"), ); } else if (selBox) { tools.push(sm("Remove", () => void askRemove(selBox), "danger")); } const legend = opts.trace && (opts.trace.back || opts.trace.forward) ? [ el("span", { className: "m02-dfdg__legend is-back", text: tt("Back") }), el("span", { className: "m02-dfdg__legend is-fwd", text: tt("Forward") }), ] : []; 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" }), ...legend, 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 || st.note ? "" : 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 }, ); // 판 옮기기 = 가운데 단추 끌기(어디서나) · Space + 왼쪽 끌기 — 왼쪽 빈 곳 끌기는 아무것도 안 함 view.addEventListener( "pointerdown", (ev) => { if (ev.button === 1 || (ev.button === 0 && spaceDown)) { ev.preventDefault(); ev.stopPropagation(); dragPan(ev); } }, { capture: true }, ); view.addEventListener("auxclick", (ev) => ev.button === 1 && ev.preventDefault()); view.addEventListener("mousedown", (ev) => ev.button === 1 && ev.preventDefault()); 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); }; const add = ( parent: Element, tag: K, attrs: Record, ) => { const n = document.createElementNS(SVG, tag); for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v); parent.append(n); return n; }; 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 traced = markOf((s) => s.some((x) => x.link === l.id)); const mark = strong && traced === " is-dim" ? "" : traced; // 고른 선은 흐리게 하지 않음 const dim = !mark && st.selected !== null && !strong ? " is-dim" : ""; const g = add(svg, "g", { class: `m02-dfdg__link${strong ? " is-strong" : ""}${dim}${mark}`, "data-link": l.id, "data-ends": [a.x, a.y, b.x, b.y].map((n) => n.toFixed(1)).join(","), }); add(g, "path", { d, class: "m02-dfdg__hit" }); add(g, "path", { d, class: "m02-dfdg__wire" }); const mid = midOf(a, b); const rule = labels[l.id] ?? linkLabel(l); if (rule) { const tag = add(g, "g", { class: "m02-dfdg__rule" }); const box = add(tag, "rect", { rx: "4", ry: "4" }); const text = add(tag, "text", { x: mid.x.toFixed(1), y: mid.y.toFixed(1), "dominant-baseline": "central", "text-anchor": "middle", }); text.textContent = rule; const w = (text.getComputedTextLength?.() || rule.length * 7) + 10; Object.entries({ x: mid.x - w / 2, y: mid.y - 9, width: w, height: 18 }).forEach(([k, v]) => box.setAttribute(k, v.toFixed(1)), ); } const broken = breaks.filter((k) => k.link === l.id); if (broken.length) { const why = broken.map((k) => k.text).join(" · "); const badge = add(g, "g", { class: "m02-dfdg__linkwarn", "data-why": why }); add(badge, "circle", { cx: mid.x.toFixed(1), cy: (mid.y - (rule ? 18 : 0)).toFixed(1), r: "8", }); const t = add(badge, "text", { x: mid.x.toFixed(1), y: (mid.y - (rule ? 18 : 0)).toFixed(1), "dominant-baseline": "central", "text-anchor": "middle", }); t.textContent = "!"; add(badge, "title", {}).textContent = why; } g.addEventListener("click", (ev) => { ev.stopPropagation(); const why = broken.map((k) => k.text).join(" · "); select(st.selected === l.id ? null : l.id, undefined, st.selected === l.id ? "" : why); }); } } 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 { 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 { ev.preventDefault(); // 끌면서 글자가 잡히지 않게 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 }; view.classList.add("is-panning"); follow( ev, (m) => { st.panX = p0.x + m.clientX - ev.clientX; st.panY = p0.y + m.clientY - ev.clientY; applyView(); }, () => view.classList.remove("is-panning"), ); } function dragLink(ev: PointerEvent, from: CfPort): void { if (ev.button !== 0 || spaceDown) return; ev.stopPropagation(); ev.preventDefault(); 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, ); }, ); } }