diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.css b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.css index 540e18ed3..9381a6c08 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.css +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.css @@ -70,8 +70,15 @@ transparent 1px ); background-size: 20px 20px; - cursor: grab; + cursor: default; touch-action: none; + /* 왼쪽 끌기로 글자가 잡히지 않게 */ + user-select: none; + -webkit-user-select: none; +} + +.m02-dfdg__view.is-panning { + cursor: grabbing; } .m02-dfdg__world { @@ -274,3 +281,132 @@ border-color: var(--cfdg-in); color: var(--cfdg-in); } + +/* ── 흐름도 더함: 선 규칙 이름표 · 흐름 길 · 끊긴 곳 · 알약 고름 ── */ +.m02-dfdg { + --dfdg-back: var(--color-accent); + --dfdg-fwd: #c2410c; +} + +:root[data-theme="dark"] .m02-dfdg { + --dfdg-fwd: #f0a35e; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .m02-dfdg { + --dfdg-fwd: #f0a35e; + } +} + +.m02-dfdg__rule rect { + fill: var(--color-surface); + stroke: var(--color-border); +} + +.m02-dfdg__rule text { + fill: var(--color-text-secondary); + font-size: 11px; +} + +.m02-dfdg__link.is-strong .m02-dfdg__rule rect { + stroke: var(--color-accent); +} + +/* 흐름 길 — 색 + 굵기 + 점선(색만으로 뜻을 안 전함) */ +.m02-dfdg__link.is-back .m02-dfdg__wire { + stroke: var(--dfdg-back); + stroke-width: 3.5; +} + +.m02-dfdg__link.is-fwd .m02-dfdg__wire { + stroke: var(--dfdg-fwd); + stroke-width: 3.5; + stroke-dasharray: 8 5; +} + +.m02-dfdg__link.is-dim .m02-dfdg__wire, +.m02-dfdg__link.is-dim .m02-dfdg__rule { + opacity: 0.25; +} + +.m02-dfdg__box.is-back { + outline: 3px solid var(--dfdg-back); + outline-offset: 2px; +} + +.m02-dfdg__box.is-fwd { + outline: 3px dashed var(--dfdg-fwd); + outline-offset: 2px; +} + +.m02-dfdg__box.is-dim { + opacity: 0.45; +} + +.m02-dfdg__pill.is-back { + box-shadow: 0 0 0 2px var(--dfdg-back); +} + +.m02-dfdg__pill.is-fwd { + outline: 2px dashed var(--dfdg-fwd); + outline-offset: 1px; +} + +.m02-dfdg__legend { + padding: 0 var(--spacing-8); + border-radius: 999px; + font-size: var(--text-caption); +} + +.m02-dfdg__legend.is-back { + border: 2px solid var(--dfdg-back); + color: var(--dfdg-back); +} + +.m02-dfdg__legend.is-fwd { + border: 2px dashed var(--dfdg-fwd); + color: var(--dfdg-fwd); +} + +/* 고른 알약 테두리 */ +.m02-dfdg__pill.is-chosen { + box-shadow: 0 0 0 2px var(--color-text); +} + +/* 끊긴 곳 */ +.m02-dfdg__warn { + position: absolute; + top: -9px; + right: -9px; + width: 18px; + height: 18px; + padding: 0; + border: 0; + border-radius: 50%; + background: var(--color-danger); + color: #fff; + font: inherit; + font-size: 12px; + font-weight: 700; + line-height: 18px; + cursor: pointer; +} + +.m02-dfdg__pill.is-broken { + border-color: var(--color-danger); +} + +.m02-dfdg__pill-warn { + color: var(--color-danger); + font-weight: 700; +} + +.m02-dfdg__linkwarn circle { + fill: var(--color-danger); +} + +.m02-dfdg__linkwarn text { + fill: #fff; + font-size: 11px; + font-weight: 700; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts index e00184f49..a6ccd0742 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts @@ -1,184 +1,39 @@ /* ============================================================================= * M02_DF_Diagram.ts - * 단가산출 다이어그램 — 자유 배치(정함 점검 8 · 9 · 12 · 「상자 · 선 약속」) - * 상자(입력 · 함수 · 출력)를 끌어 아무 자리에 놓음(on.move) · 판 끌기 · 확대 / 축소 · 「한눈에」 · 「줄 맞추기」 · - * 상자 = 종류 · 이름 · 오른쪽 위 단위 · 색 · 왼쪽 받는 알약 · 오른쪽 내보내는 알약(알약에 지금 값) · - * 선 = 굽은 선 · 내보내는 알약을 끌어 받는 알약에 놓으면 on.link(막히면 까닭 한 줄) · 고른 상자와 이어진 선만 진하게. - * SheetLogic 다이어그램(M01_SL_Diagram)을 옮겨 고쳐 씀 — 그 파일에 기대지 않음. 그리기만(바꾸는 것은 부른 쪽이 Model 로). + * 단가산출 흐름도 — 다이어그램(흐름도 정함 7 · 8 · 13 · 1판 「상자 · 선 약속」) + * 자유 배치 상자(입력 · 함수 · 출력) · 굽은 선 + 가운데 선 규칙 이름표 · 흐름 길(온 길 · 가는 길) 칠하기 · + * 끊긴 곳 표시 · 알약 · 선 · 상자 고르기. 마우스 — 판 옮기기 = 가운데 단추 끌기(또는 Space + 왼쪽 끌기) · + * 고르기 = 왼쪽 누름 · 상자 옮기기 = 상자 머리 왼쪽 끌기 · 휠 = 확대 · 글자 선택 막음. + * 그리기만(바꾸는 것은 부른 쪽이 Model 로) · 순수 부분은 `M02_DF_Diagram_Parts.ts`. * ========================================================================== */ -import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements"; +import type { CfBox, CfKind, CfLogic, CfPill, CfPort } from "./M02_DF_Model"; import { - portsIn, - portsOut, - type CfBox, - type CfKind, - type CfLogic, - type CfPill, - type CfPort, -} from "./M02_DF_Model"; + 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"; -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))); - -/* ── 그리기 ─────────────────────────────────────────────────────── */ +export * from "./M02_DF_Diagram_Parts"; interface HostState { selected: string | null; linking: CfPort | null; - pill: { box: string; name: string; dir: "in" | "out" } | null; + pill: DfPillSel | null; note: string; zoom: number; panX: number; @@ -189,18 +44,16 @@ interface HostState { 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); -}; +/** 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, @@ -219,15 +72,19 @@ export function renderDiagram( 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 }); - const select = (id: string | null): void => { - st.selected = id; - st.pill = null; - st.note = ""; - on.select(id); + 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 => { @@ -236,6 +93,23 @@ export function renderDiagram( 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" }); @@ -247,6 +121,7 @@ export function renderDiagram( 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)}%`; @@ -257,18 +132,22 @@ export function renderDiagram( 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 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" : "", - st.linking && dir === "in" && b.id !== st.linking.box ? "is-target" : "", - ].join(" "), + 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, @@ -277,6 +156,7 @@ export function renderDiagram( 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]) }), ], @@ -284,10 +164,9 @@ export function renderDiagram( 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 (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 })); @@ -318,21 +197,25 @@ export function renderDiagram( 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: tt(`K_${b.kind}` as keyof typeof TEXT) }), + 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" : ""}`, + 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": `${tt(`K_${b.kind}` as keyof typeof TEXT)} · ${b.name}`, + "aria-label": `${kind} · ${b.name}`, }, children: [ head, @@ -352,6 +235,13 @@ export function renderDiagram( }), ], }), + ...(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`; @@ -364,7 +254,7 @@ export function renderDiagram( }); node.addEventListener("keydown", (ev) => { if (ev.target !== node) return; - if (ev.key === "Enter" || ev.key === " ") { + if (ev.key === "Enter") { ev.preventDefault(); node.click(); } @@ -380,14 +270,15 @@ export function renderDiagram( on.move(b.id, Math.round(b.x + d[0]), Math.round(b.y + d[1])); } }); - head.addEventListener("pointerdown", (ev) => dragBox(ev, b, node)); + head.addEventListener("pointerdown", (ev) => { + if (ev.button === 0 && !spaceDown) 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; @@ -416,7 +307,7 @@ export function renderDiagram( 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, @@ -442,31 +333,39 @@ export function renderDiagram( ); } else if (st.pill) { const p = st.pill; - const mine = (e: CfPort): boolean => e.box === p.box && e.name === p.name; + 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.name]) || "—"}`; + 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.name, now, dir].filter(Boolean).join(" · ") })); + 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.name }), (st.pill = null), redraw()), + () => ((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: line }), + 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", @@ -476,6 +375,7 @@ export function renderDiagram( 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)), @@ -494,7 +394,10 @@ export function renderDiagram( children: [ ...tools, el("span", { className: "m02-dfdg__note", text: st.note }), - el("span", { className: "m02-dfdg__hint", text: tools.length ? "" : tt("Hint") }), + 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") }), @@ -521,14 +424,24 @@ export function renderDiagram( }, { passive: false }, ); - view.addEventListener("pointerdown", (ev) => { - if (ev.target !== view && ev.target !== world && ev.target !== svg) return; - dragPan(ev); - }); + // 판 옮기기 = 가운데 단추 끌기(어디서나) · 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 => { @@ -539,6 +452,16 @@ export function renderDiagram( 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"); @@ -548,24 +471,56 @@ export function renderDiagram( 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); + 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(); - select(st.selected === l.id ? null : l.id); + const why = broken.map((k) => k.text).join(" · "); + select(st.selected === l.id ? null : l.id, undefined, st.selected === l.id ? "" : why); }); - svg.append(g); } } const firstFit = (): void => { @@ -584,7 +539,6 @@ export function renderDiagram( 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; @@ -609,6 +563,7 @@ export function renderDiagram( } 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; @@ -630,6 +585,7 @@ export function renderDiagram( function dragPan(ev: PointerEvent): void { const p0 = { x: st.panX, y: st.panY }; + view.classList.add("is-panning"); follow( ev, (m) => { @@ -637,12 +593,14 @@ export function renderDiagram( st.panY = p0.y + m.clientY - ev.clientY; applyView(); }, - () => undefined, + () => 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); diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts b/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts new file mode 100644 index 000000000..2b9ac51e0 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts @@ -0,0 +1,233 @@ +/* ============================================================================= + * M02_DF_Diagram_Parts.ts + * 단가산출 흐름도 다이어그램의 순수 부분(DOM 없음) — 글 · 약속 모양 · 떨군 자리 풀기 · 줄 맞추기 · + * 한눈에 맞춤 · 알약 값 글 · 흐름 길 가르기. 그리기는 `M02_DF_Diagram.ts`. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { + portsIn, + portsOut, + type CfBox, + type CfKind, + type CfLogic, + type CfPill, + type CfPort, + type CfLink, + type CfStep, +} from "./M02_DF_Model"; +import type { CfBreak } from "./M02_DF_Eval"; +import { ruleLabel } from "./M02_DF_Rule"; + +export 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: [ + "상자 머리를 끌어 옮김 · 가운데 단추(또는 Space + 끌기)로 판 옮김 · 휠로 확대", + "Drag a head to move · middle button (or Space + drag) to pan · wheel to zoom", + ], + Break: ["끊긴 곳", "Break"], + Back: ["온 길", "Came from"], + Forward: ["가는 길", "Goes to"], +} as const; + +export 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 DfPillSel { + box: string; + port: string; + dir: "in" | "out"; +} + +export interface DiagramOn { + /** 상자 · 선 id · 없음 — 알약을 고르면 상자 id 와 알약 */ + select(id: string | null, pill?: DfPillSel): 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; + /** 고른 알약 */ + selectedPill?: DfPillSel | null; + /** 선 id → 선 규칙 글 — 없으면 선의 규칙(CfLink.rule)에서 · 규칙 없으면 이름표 없음 */ + labels?: Record; + /** 흐름 길 — Model traceBack · traceForward 걸음 */ + trace?: { back?: CfStep[]; forward?: CfStep[] } | null; + /** 끊긴 곳 — Eval breaks */ + breaks?: CfBreak[]; +} + +/* ── 알약 · 자리 ────────────────────────────────────────────────────── */ + +/** 알약 — 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; + +export const clampZoom = (z: number): number => Math.min(2, Math.max(0.25, +z.toFixed(3))); + +/** 모두 보이게 맞춤 — 상자 둘레 → 확대 · 판 자리 · 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) }; +} + +/** 알약 값 글 — 수는 천 단위 쉼표 · 소수 셋째 자리까지(끝 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 type TraceMark = "back" | "fwd" | "dim" | ""; + +export function traceOf( + trace: DiagramOpts["trace"], + pick: (steps: CfStep[]) => boolean, +): TraceMark { + if (!trace || (!trace.back?.length && !trace.forward?.length)) return ""; + if (trace.back && pick(trace.back)) return "back"; + if (trace.forward && pick(trace.forward)) return "fwd"; + return "dim"; +} + +/** 선 위 규칙 글 — Rule 의 ruleLabel · 규칙 없으면 「」 */ +export const linkLabel = (link: CfLink): string => (link.rule ? ruleLabel(link.rule) : ""); + +/** 굽은 선 가운데 점 — 양 끝 손잡이가 마주 보는 베지어라 t = 0.5 가 두 끝의 가운데 */ +export const midOf = ( + a: { x: number; y: number }, + b: { x: number; y: number }, +): { x: number; y: number } => ({ + x: (a.x + b.x) / 2, + y: (a.y + b.y) / 2, +}); diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts b/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts index cb0516e43..9b6380065 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts @@ -347,7 +347,8 @@ function otherLogic( } const inner: CfValues = {}; for (const d of designInputs(other)) { - const v = d.port in arrive ? arrive[d.port] : given[d.port]; + // 값 차례 = 이어진 선 > 넘긴 값 > 상자에 둔 예시값 > 그 로직 자기 예시값(inner 에 안 넣음) + const v = d.port in arrive ? arrive[d.port] : (given[d.port] ?? ref?.examples?.[d.port]); if (v !== undefined && v !== null) (inner[d.box] ??= {})[d.port] = v; } const ev = evalLogic(other, inner, { logics: opts.logics, depth: (opts.depth ?? 0) + 1 }); diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts index f6263d2ce..541fd358c 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts @@ -227,7 +227,11 @@ export function checkMaterial(el: CfElementRef, others: CfElementRef[]): string if (!el.name) return "자리 이름을 적어 주세요"; if (others.some((o) => o.name === el.name)) return "같은 이름의 알약이 이미 있음"; if (el.mode === "rule" && !el.rule?.from.name) return "규칙에 쓸 변수 알약을 골라 주세요"; - if (el.mode !== "fixed" && !Object.keys(el.scope ?? {}).length) + if ( + el.mode !== "fixed" && + !(el.mode === "choice" && el.default) && + !Object.keys(el.scope ?? {}).length + ) return "범위(구분 · 상세구분 · 찾는 말) 하나는 정해 주세요"; return null; } @@ -255,7 +259,7 @@ export function logicRefOf(key: string, other: CfLogic): CfLogicRef { }; } -/** 다른 로직 입력 넣기 · 빼기(null) — 빼면 변수 상태로 돌아가고 그 알약에서 나간 선도 지움 */ +/** 다른 로직 입력 넣기 · 빼기(null) — 빼면 변수 상태로 돌아가고 이 상자의 알약에 닿은 선은 모두 지움 */ export function setLogicInput(lg: CfLogic, id: string, ref: CfLogicRef | null): CfLogic { return { ...lg, @@ -266,7 +270,7 @@ export function setLogicInput(lg: CfLogic, id: string, ref: CfLogicRef | null): : { mode: "variable", elements: [], variables: [] }; return { ...b, input, unit: unitOfInput(input) }; }), - links: lg.links.filter((l) => l.from.box !== id), + links: lg.links.filter((l) => l.from.box !== id && l.to.box !== id), // 나가던 선 · 그 로직의 받는 알약으로 들어오던 선 모두 }; } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Material.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Material.ts index d24043210..0d2e45136 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Material.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Material.ts @@ -291,7 +291,7 @@ function itemList( export async function previewOf( el1: CfElementRef, lg: CfLogic, -): Promise<{ found: number; names: string[]; cheapest: MatItem | null }> { +): Promise<{ found: number; names: string[]; cheapest: MatItem | null; unit: string }> { const scope = el1.scope ?? {}; const word = scope.찾는말 ?? ""; if (el1.mode === "rule" && el1.rule) { @@ -300,7 +300,7 @@ export async function previewOf( ); const value = pill?.example; if (value === null || value === undefined || value === "") - return { found: 0, names: [], cheapest: null }; + return { found: 0, names: [], cheapest: null, unit: "" }; const r = await searchMaterials({ q: `${word} ${value}`.trim(), sub: scope.구분, @@ -313,6 +313,7 @@ export async function previewOf( found: hit.length, names: hit.slice(0, 3).map((i) => `${i.name} ${i.spec}`.trim()), cheapest: cheapestOf(hit), + unit: commonUnit(hit), }; } const r = await searchMaterials({ @@ -326,9 +327,14 @@ export async function previewOf( found: r.total, names: r.items.slice(0, 3).map((i) => `${i.name} ${i.spec}`.trim()), cheapest: cheapestOf(r.items), + unit: commonUnit(r.items), }; } +/** 잡힌 품목 단위가 모두 같으면 그 단위 · 아니면 빈 글 */ +const commonUnit = (items: MatItem[]): string => + items.length && items.every((i) => i.unit === items[0].unit) ? items[0].unit : ""; + const cheapestOf = (items: MatItem[]): MatItem | null => items .filter((i) => i.price !== null) @@ -541,7 +547,11 @@ export function renderMaterial( const why = checkMaterial(cand, els); name.setError(why ?? undefined); if (why) return void showToast(why, "error"); - on.change(addEl(lg, cand)); + // 잡히는 재료의 단위가 하나로 같으면 그 단위를 알약 단위로 + void previewOf(cand, lg).then( + (p) => on.change(addEl(lg, p.unit ? { ...cand, unit: p.unit } : cand)), + () => on.change(addEl(lg, cand)), + ); }, }); const clear = createButton({ diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Model.ts b/M02_MasterTemplete/DataFlow/M02_DF_Model.ts index 73a18d287..f8ee64e95 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Model.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Model.ts @@ -64,6 +64,8 @@ export interface CfLogicRef { outputs: CfPill[]; /** 받는 알약 = 그 로직의 「설계 때 정할 것」(변수 · 재료 자리) */ designs: CfPill[]; + /** 선 없는 받는 알약의 예시값(키 = 받는 알약 이름) — 값 차례 = 이어진 선 > 넘긴 값 > 이것 > 그 로직 자기 예시값 */ + examples?: Record; } export interface CfInput { @@ -154,6 +156,8 @@ export interface CfDesign { unit: string; variable?: CfVariable; element?: CfElementRef; + /** 보일 예시값 — 변수 = variable.example · 다른 로직 = logic.examples(없으면 빈 칸 → 그 로직 자기 예시값) */ + example?: number | string | null; } /** 사용자 요소 — 그룹에 없는 새 요소(마스터에 안 씀 · `_data` 에 저장) */ @@ -567,7 +571,14 @@ export function designInputs(lg: CfLogic): CfDesign[] { for (const b of lg.boxes) { const vars = b.input?.variables ?? b.func?.variables ?? []; for (const v of vars) - out.push({ box: b.id, port: v.name, kind: "변수", unit: v.unit || "-", variable: clone(v) }); + out.push({ + box: b.id, + port: v.name, + kind: "변수", + unit: v.unit || "-", + variable: clone(v), + example: v.example, + }); for (const e of b.input?.mode === "group" ? b.input.elements : []) if (e.mode === "choice") out.push({ @@ -580,7 +591,13 @@ export function designInputs(lg: CfLogic): CfDesign[] { if (b.input?.mode === "logic") for (const d of b.input.logic?.designs ?? []) if (!lg.links.some((l) => l.to.box === b.id && l.to.name === d.name)) - out.push({ box: b.id, port: d.name, kind: "다른 로직", unit: d.unit || "-" }); + out.push({ + box: b.id, + port: d.name, + kind: "다른 로직", + unit: d.unit || "-", + example: b.input.logic?.examples?.[d.name] ?? null, + }); } return out; } diff --git a/resources/tester/test_m02_df_inputpanel_material.py b/resources/tester/test_m02_df_inputpanel_material.py index 43355562d..7f5b2657b 100644 --- a/resources/tester/test_m02_df_inputpanel_material.py +++ b/resources/tester/test_m02_df_inputpanel_material.py @@ -230,10 +230,13 @@ def test_다른_로직_상자는_출력_단위를_따르고_빼면_변수로_돌 assert box["unit"] == "㎥/시간" assert call("pillNames", box) == ["작업량"] lg["links"] = [ - {"id": "l1", "from": {"box": "b1", "name": "작업량"}, "to": {"box": "b2", "name": "x"}} + {"id": "l1", "from": {"box": "b1", "name": "작업량"}, "to": {"box": "b2", "name": "x"}}, + {"id": "l2", "from": {"box": "b2", "name": "관경"}, "to": {"box": "b1", "name": "q"}}, + {"id": "l3", "from": {"box": "b2", "name": "관경"}, "to": {"box": "b3", "name": "y"}}, ] back = call("setLogicInput", lg, "b1", None) - assert back["boxes"][0]["input"]["mode"] == "variable" and back["links"] == [] + assert back["boxes"][0]["input"]["mode"] == "variable" + assert [x["id"] for x in back["links"]] == ["l3"] assert call("reconcileUnit", lg, "b1") == lg # 다른 로직 상자 단위는 안 건드림 diff --git a/resources/tester/test_m02_df_model.py b/resources/tester/test_m02_df_model.py index c8b0663bd..56bdf7c3d 100644 --- a/resources/tester/test_m02_df_model.py +++ b/resources/tester/test_m02_df_model.py @@ -592,3 +592,20 @@ def test_끝수_함수_출력_출력_상자() -> None: "out([a, E.evalLogic(lg, V).cost.노무비]);" ) assert got == [537000, 540000] + + +def test_다_다른_로직_받는_알약_예시값_차례() -> None: + got = _run( + LOGIC + + "lg = M.updateBox(lg, other.id, { input: { ...lg.boxes[1].input, logic: { ...lg.boxes[1].input.logic, examples: { 계수: 10, 면적: 99 } } } });\n" + "const a = E.evalLogic(lg, {}, { logics: LOGICS }).cost.경비;\n" + "const b = E.evalLogic(lg, { [other.id]: { 계수: 2 } }, { logics: LOGICS }).cost.경비;\n" + "const noEx = M.updateBox(lg, other.id, { input: { ...lg.boxes[1].input, logic: { ...lg.boxes[1].input.logic, examples: undefined } } });\n" + "out([a, b, E.evalLogic(noEx, {}, { logics: LOGICS }).cost.경비," + " M.designInputs(lg).map(d => [d.kind, d.port, d.example]), M.designInputs(noEx).map(d => d.example)]);" + ) + a, b, c, designs, plain = got + # 면적 = 이어진 선(길이 7) > 예시값 99 · 계수 = 상자 예시값 10 · 넘긴 값 2 가 이김 · 없으면 그 로직 예시값 3 + assert (a, b, c) == (70000, 14000, 21000) + assert designs == [["변수", "길이", 7], ["다른 로직", "계수", 10]] + assert plain == [7, None]