From 7c1266bcb44989cb9d226725b632e222126be6f1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:01:29 +0900 Subject: [PATCH] =?UTF-8?q?feat(M01):=20=EC=8B=9C=ED=8A=B8=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=9C=84=20=EA=B0=80=EB=A1=9C=20=EB=8B=A4=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=EA=B7=B8=EB=9E=A8=20=EC=B4=88=EC=95=88=20=E2=80=94=20?= =?UTF-8?q?=EC=83=81=EC=9E=90=20=C2=B7=20=EC=95=8C=EC=95=BD=20=C2=B7=20?= =?UTF-8?q?=EC=84=A0=20=C2=B7=20=EB=81=8C=EC=96=B4=20=EC=9E=87=EA=B8=B0(PL?= =?UTF-8?q?AN=2035-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z --- M01_MasterData/SheetLogic/M01_SL_Diagram.css | 251 +++++++++ M01_MasterData/SheetLogic/M01_SL_Diagram.ts | 504 +++++++++++++++++++ 2 files changed, 755 insertions(+) create mode 100644 M01_MasterData/SheetLogic/M01_SL_Diagram.css create mode 100644 M01_MasterData/SheetLogic/M01_SL_Diagram.ts diff --git a/M01_MasterData/SheetLogic/M01_SL_Diagram.css b/M01_MasterData/SheetLogic/M01_SL_Diagram.css new file mode 100644 index 000000000..22c75a9ab --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Diagram.css @@ -0,0 +1,251 @@ +/* 시트 로직 — 위 가로 다이어그램 */ +.m01-sldg { + --sldg-in: var(--color-accent); + --sldg-out: #c2610c; + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; + font-size: var(--text-body-sm); + outline: none; +} + +:root[data-theme="dark"] .m01-sldg { + --sldg-out: #e5a866; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .m01-sldg { + --sldg-out: #e5a866; + } +} + +.m01-sldg__bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); + min-height: 2rem; +} + +.m01-sldg__note { + color: var(--color-danger); +} + +.m01-sldg__muted { + margin: 0; + color: var(--color-text-muted); +} + +.m01-sldg__menu { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4); + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); +} + +.m01-sldg__menu[hidden] { + display: none; +} + +/* 띠 — 화면 위 3분의 1 안쪽 · 길면 가로로 밀기 */ +.m01-sldg__band { + max-height: 33vh; + overflow: auto; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--color-accent) 5%, var(--color-canvas)); +} + +.m01-sldg__inner { + position: relative; + display: grid; + grid-template-columns: repeat(5, minmax(11rem, 1fr)); + gap: var(--spacing-24); + min-width: max-content; + padding: var(--spacing-12) var(--spacing-16); +} + +.m01-sldg__lines { + position: absolute; + inset: 0 auto auto 0; + overflow: visible; + pointer-events: none; + z-index: 0; +} + +.m01-sldg__col { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + gap: var(--spacing-12); + min-width: 11rem; +} + +.m01-sldg__col-head { + color: var(--color-text-muted); + font-size: var(--text-caption); + letter-spacing: 0.04em; +} + +/* 상자 */ +.m01-sldg__box { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); + cursor: pointer; +} + +.m01-sldg__box:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +.m01-sldg__box.is-selected { + border: 2px solid var(--color-accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 18%, transparent); +} + +.m01-sldg__box-head { + display: flex; + flex-direction: column; +} + +.m01-sldg__kind { + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.m01-sldg__name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.m01-sldg__pills { + display: flex; + justify-content: space-between; + gap: var(--spacing-8); +} + +.m01-sldg__ins, +.m01-sldg__outs { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + min-width: 0; +} + +.m01-sldg__outs { + align-items: flex-end; +} + +/* 알약 — 입력 파랑 · 출력 주황 · 빈 자리 회색 */ +.m01-sldg__pill { + display: inline-flex; + align-items: baseline; + gap: var(--spacing-4); + max-width: 10rem; + padding: 0 var(--spacing-8); + border: 1px solid var(--sldg-in); + border-radius: 999px; + background: color-mix(in srgb, var(--sldg-in) 12%, var(--color-surface)); + color: var(--sldg-in); + font: inherit; + font-size: var(--text-caption); + cursor: pointer; + white-space: nowrap; +} + +.m01-sldg__pill.is-out { + border-color: var(--sldg-out); + background: color-mix(in srgb, var(--sldg-out) 14%, var(--color-surface)); + color: var(--sldg-out); + cursor: grab; + touch-action: none; +} + +.m01-sldg__pill.is-empty { + border-style: dashed; + border-color: var(--color-text-muted); + background: transparent; + color: var(--color-text-muted); +} + +.m01-sldg__pill.is-chosen { + box-shadow: 0 0 0 2px currentColor; +} + +.m01-sldg.is-linking .m01-sldg__pill.is-target { + box-shadow: 0 0 0 2px var(--sldg-in); + animation: m01-sldg-blink 1s ease-in-out infinite alternate; +} + +@keyframes m01-sldg-blink { + to { + background: color-mix(in srgb, var(--sldg-in) 30%, var(--color-surface)); + } +} + +.m01-sldg__pill-name { + overflow: hidden; + text-overflow: ellipsis; +} + +.m01-sldg__pill-value { + color: var(--color-text-secondary); + font-variant-numeric: tabular-nums; +} + +.m01-sldg__pill-value:empty { + display: none; +} + +/* 선 */ +.m01-sldg__edge { + pointer-events: auto; + cursor: pointer; +} + +.m01-sldg__hit { + fill: none; + stroke: transparent; + stroke-width: 12; +} + +.m01-sldg__wire { + fill: none; + stroke: var(--color-text-muted); + stroke-width: 1.5; +} + +.m01-sldg__edge.is-strong .m01-sldg__wire { + stroke: var(--color-accent); + stroke-width: 3; +} + +.m01-sldg__edge.is-dim .m01-sldg__wire { + opacity: 0.3; +} + +.m01-sldg__drag { + fill: none; + stroke: var(--sldg-out); + stroke-width: 2; + stroke-dasharray: 5 4; +} + +@media (prefers-reduced-motion: reduce) { + .m01-sldg.is-linking .m01-sldg__pill.is-target { + animation: none; + } +} diff --git a/M01_MasterData/SheetLogic/M01_SL_Diagram.ts b/M01_MasterData/SheetLogic/M01_SL_Diagram.ts new file mode 100644 index 000000000..c32d15315 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Diagram.ts @@ -0,0 +1,504 @@ +/* ============================================================================= + * 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; +} + +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; + 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); + } + }; + requestAnimationFrame(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 을 한 번 삼킴 + window.addEventListener("click", (c) => c.stopPropagation(), { capture: true, once: true }); + tryConnect(from, target); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + } +}