diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Diagram.css b/M02_MasterTemplete/CostFlow/M02_CF_Diagram.css new file mode 100644 index 000000000..fc9c8ee8f --- /dev/null +++ b/M02_MasterTemplete/CostFlow/M02_CF_Diagram.css @@ -0,0 +1,258 @@ +/* 단가산출 다이어그램 — 자유 배치 */ +.m02-cfdg { + --cfdg-in: var(--color-accent); + --cfdg-out: #c2610c; + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; + font-size: var(--text-body-sm); +} + +:root[data-theme="dark"] .m02-cfdg { + --cfdg-out: #e5a866; + --cfdg-lift: #fff; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .m02-cfdg { + --cfdg-out: #e5a866; + --cfdg-lift: #fff; + } +} + +.m02-cfdg__bar, +.m02-cfdg__tools { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.m02-cfdg__tools { + min-height: 1.75rem; +} + +.m02-cfdg__title { + font-size: var(--text-body); +} + +.m02-cfdg__gap { + flex: 1; +} + +.m02-cfdg__zoom { + min-width: 3rem; + text-align: center; + font-variant-numeric: tabular-nums; + color: var(--color-text-secondary); +} + +.m02-cfdg__note { + color: var(--color-danger); +} + +.m02-cfdg__hint { + margin: 0; + color: var(--color-text-muted); +} + +/* 판 — 화면 위쪽 큰 영역 */ +.m02-cfdg__view { + position: relative; + height: max(360px, 55vh); + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background-color: var(--color-canvas); + background-image: radial-gradient( + color-mix(in srgb, var(--color-text-muted) 35%, transparent) 1px, + transparent 1px + ); + background-size: 20px 20px; + cursor: grab; + touch-action: none; +} + +.m02-cfdg__world { + position: absolute; + inset: 0 auto auto 0; + width: 1px; + height: 1px; + transform-origin: 0 0; +} + +.m02-cfdg__lines { + position: absolute; + inset: 0 auto auto 0; + width: 1px; + height: 1px; + overflow: visible; + pointer-events: none; +} + +/* 상자 — 종류별 기본 색 · 상자마다 바꾼 색(--cf-color) */ +.m02-cfdg__box { + position: absolute; + display: flex; + flex-direction: column; + gap: var(--spacing-4); + min-width: 11rem; + max-width: 16rem; + padding: 0 0 var(--spacing-8); + border: 1px solid color-mix(in srgb, var(--cf-color) 55%, var(--color-border)); + border-radius: var(--radius-lg); + background: var(--color-surface); + box-shadow: 0 1px 3px rgb(0 0 0 / 12%); + cursor: pointer; +} + +.m02-cfdg__box:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +.m02-cfdg__box.is-selected { + border: 2px solid var(--cf-color); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--cf-color) 22%, transparent); +} + +.m02-cfdg__head { + display: grid; + grid-template-columns: 1fr auto; + align-items: baseline; + gap: 0 var(--spacing-8); + padding: var(--spacing-4) var(--spacing-8); + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + background: color-mix(in srgb, var(--cf-color) 16%, var(--color-surface)); + cursor: move; +} + +.m02-cfdg__kind { + grid-column: 1; + /* 어두운 화면은 종류 색을 밝게 섞음 */ + color: color-mix(in srgb, var(--cf-color) 55%, var(--cfdg-lift, var(--cf-color))); + font-size: var(--text-caption); + font-weight: 600; +} + +.m02-cfdg__unit { + grid-column: 2; + grid-row: 1; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.m02-cfdg__name { + grid-column: 1 / -1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.m02-cfdg__pills { + display: flex; + justify-content: space-between; + gap: var(--spacing-8); + padding: 0 var(--spacing-8); +} + +.m02-cfdg__ins, +.m02-cfdg__outs { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + min-width: 0; +} + +.m02-cfdg__outs { + align-items: flex-end; +} + +/* 알약 — 받는 파랑 · 내보내는 주황 · 빈 자리 회색 */ +.m02-cfdg__pill { + display: inline-flex; + align-items: baseline; + gap: var(--spacing-4); + max-width: 9rem; + padding: 0 var(--spacing-8); + border: 1px solid var(--cfdg-in); + border-radius: 999px; + background: color-mix(in srgb, var(--cfdg-in) 12%, var(--color-surface)); + color: var(--cfdg-in); + font: inherit; + font-size: var(--text-caption); + cursor: pointer; + white-space: nowrap; +} + +.m02-cfdg__pill.is-out { + border-color: var(--cfdg-out); + background: color-mix(in srgb, var(--cfdg-out) 14%, var(--color-surface)); + color: var(--cfdg-out); + cursor: grab; + touch-action: none; +} + +.m02-cfdg__pill.is-empty { + border-style: dashed; + border-color: var(--color-text-muted); + background: transparent; + color: var(--color-text-muted); +} + +.m02-cfdg__pill.is-chosen { + box-shadow: 0 0 0 2px currentColor; +} + +.m02-cfdg.is-linking .m02-cfdg__pill.is-target { + box-shadow: 0 0 0 2px var(--cfdg-in); +} + +.m02-cfdg__pill-name { + overflow: hidden; + text-overflow: ellipsis; +} + +.m02-cfdg__pill-value { + color: var(--color-text-secondary); + font-variant-numeric: tabular-nums; +} + +.m02-cfdg__pill-value:empty { + display: none; +} + +/* 선 — 굽은 선 */ +.m02-cfdg__link { + pointer-events: auto; + cursor: pointer; +} + +.m02-cfdg__hit { + fill: none; + stroke: transparent; + stroke-width: 12; +} + +.m02-cfdg__wire { + fill: none; + stroke: var(--color-text-muted); + stroke-width: 1.5; +} + +.m02-cfdg__link.is-strong .m02-cfdg__wire { + stroke: var(--color-accent); + stroke-width: 3; +} + +.m02-cfdg__link.is-dim .m02-cfdg__wire { + opacity: 0.25; +} + +.m02-cfdg__drag { + fill: none; + stroke: var(--cfdg-out); + stroke-width: 2; + stroke-dasharray: 5 4; +} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Diagram.ts b/M02_MasterTemplete/CostFlow/M02_CF_Diagram.ts new file mode 100644 index 000000000..d8f55a7b5 --- /dev/null +++ b/M02_MasterTemplete/CostFlow/M02_CF_Diagram.ts @@ -0,0 +1,660 @@ +/* ============================================================================= + * M02_CF_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_CF_Model"; +import "./M02_CF_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"], + 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[] } { + try { + return { ins: portsIn(box), outs: portsOut(box) }; + } catch { + if (box.kind === "input") { + const inp = box.input; + const outs = [ + ...(inp?.elements ?? []).map((e) => ({ name: e.name, unit: e.unit })), + ...(inp?.table ? [{ name: "표", unit: "-" }] : []), + ...(inp?.variables ?? []).map((v) => ({ name: v.name, unit: v.unit })), + ]; + return { ins: [], outs }; + } + if (box.kind === "function") { + const f = box.func; + return { + ins: (f?.inputs ?? []).map((p) => ({ name: p.port, unit: "-" })), + outs: f ? [{ name: f.output.name, unit: f.output.unit }] : [], + }; + } + return { ins: [{ name: "값", unit: box.unit }], outs: [] }; + } +} + +/** 떨군 자리 → 받을 끝 · 상자에 떨구면 같은 이름 알약(받는 알약이 하나면 그것) · 못 정하면 까닭 */ +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); + 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 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.min(view.w / (x1 - x0), view.h / (y1 - y0), 1.5)); + return { + zoom, + panX: (view.w - (x1 - x0) * zoom) / 2 - x0 * zoom, + panY: (view.h - (y1 - y0) * zoom) / 2 - y0 * zoom, + }; +} + +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"; + +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); +}; + +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-cfdg__world" }); + const svg = document.createElementNS(SVG, "svg"); + svg.classList.add("m02-cfdg__lines"); + world.append(svg); + const view = el("div", { + className: "m02-cfdg__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-cfdg__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-cfdg__pill-name", text: wired ? p.name : `? ${p.name}` }), + el("span", { className: "m02-cfdg__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 boxEl = (b: CfBox): HTMLElement => { + const { ins, outs } = pillsOf(b); + const head = el("div", { + className: "m02-cfdg__head", + children: [ + el("span", { className: "m02-cfdg__kind", text: tt(`K_${b.kind}` as keyof typeof TEXT) }), + el("strong", { className: "m02-cfdg__name", text: b.name }), + el("span", { className: "m02-cfdg__unit", text: b.unit || "-" }), + ], + }); + const node = el("div", { + className: `m02-cfdg__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-cfdg__pills", + children: [ + el("div", { className: "m02-cfdg__ins", children: ins.map((p) => pillEl(b, p, "in")) }), + el("div", { + className: "m02-cfdg__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-cfdg__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-cfdg__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-cfdg__bar", + children: [ + el("strong", { className: "m02-cfdg__title", text: tt("Title") }), + addBtn("input", "AddInput"), + addBtn("function", "AddFunc"), + addBtn("output", "AddOutput"), + el("span", { className: "m02-cfdg__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-cfdg${st.linking ? " is-linking" : ""}`, + children: [ + bar, + el("div", { + className: "m02-cfdg__tools", + children: [ + ...tools, + el("span", { className: "m02-cfdg__note", text: st.note }), + el("span", { className: "m02-cfdg__hint", text: tools.length ? "" : tt("Hint") }), + ], + }), + lg.boxes.length ? view : el("p", { className: "m02-cfdg__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-cfdg__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-cfdg__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-cfdg__hit", "m02-cfdg__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-cfdg__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-cfdg__pill"); + const bx = hit?.closest(".m02-cfdg__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, + ); + }, + ); + } +}