From d562f099351698dd990dcf63fff81ca84aea94f1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 19:42:34 +0900 Subject: [PATCH 01/14] =?UTF-8?q?feat(M02):=20=ED=9D=90=EB=A6=84=EB=8F=84?= =?UTF-8?q?=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20=E2=80=94=20?= =?UTF-8?q?=EC=84=A0=20=EA=B7=9C=EC=B9=99=20=EC=9D=B4=EB=A6=84=ED=91=9C=20?= =?UTF-8?q?=C2=B7=20=ED=9D=90=EB=A6=84=20=EA=B8=B8=20=EC=B9=A0=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EB=81=8A=EA=B8=B4=20=EA=B3=B3=20=C2=B7=20?= =?UTF-8?q?=EC=95=8C=EC=95=BD=20=EA=B3=A0=EB=A6=84=20=C2=B7=20=EA=B0=80?= =?UTF-8?q?=EC=9A=B4=EB=8D=B0=20=EB=8B=A8=EC=B6=94=20=ED=8C=90=20=EC=98=AE?= =?UTF-8?q?=EA=B8=B0=EA=B8=B0=20=C2=B7=20=EA=B8=80=EC=9E=90=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EB=A7=89=EC=9D=8C(PLAN=2035-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 순수 부분을 M02_DF_Diagram_Parts.ts 로 나눔(700줄 안) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z --- .../DataFlow/M02_DF_Diagram.css | 138 +++++- M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts | 422 ++++++++---------- .../DataFlow/M02_DF_Diagram_Parts.ts | 238 ++++++++++ 3 files changed, 564 insertions(+), 234 deletions(-) create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts 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..dbcdcf73d 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts @@ -1,184 +1,38 @@ /* ============================================================================= * 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, + 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 +43,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 +71,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 +92,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 +120,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 +131,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.port?.box === b.id && k.port.name === 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.pills?.some((x) => x.box === b.id && x.name === p.name)), attrs: { type: "button", "data-box": b.id, @@ -277,6 +155,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 +163,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?.why ?? ""); }); if (dir === "out") btn.addEventListener("pointerdown", (ev) => dragLink(ev, { box: b.id, name: p.name })); @@ -318,21 +196,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.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" : ""}` + + markOf((s) => !!s.boxes?.includes(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 +234,13 @@ export function renderDiagram( }), ], }), + ...(broken.length + ? [ + warn(broken.map((k) => k.why).join(" · "), () => + select(b.id, undefined, broken.map((k) => k.why).join(" · ")), + ), + ] + : []), ], }); node.style.left = `${b.x}px`; @@ -364,7 +253,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 +269,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 +306,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 +332,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]; 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 +374,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 +393,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 +423,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 +451,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 +470,55 @@ 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 mark = markOf((s) => !!s.links?.includes(l.id)); + 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]; + 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.why).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.why).join(" · "); + select(st.selected === l.id ? null : l.id, undefined, st.selected === l.id ? "" : why); }); - svg.append(g); } } const firstFit = (): void => { @@ -584,7 +537,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 +561,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 +583,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 +591,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..b618b1a03 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts @@ -0,0 +1,238 @@ +/* ============================================================================= + * 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, +} from "./M02_DF_Model"; + +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 DfTraceSet { + boxes?: string[]; + links?: string[]; + pills?: { box: string; name: string }[]; +} + +/** 끊긴 곳 — 상자 · 선 · 알약 중 하나 + 까닭 */ +export interface DfBreak { + box?: string; + link?: string; + port?: { box: string; name: string }; + why: string; +} + +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 → 선 규칙 글(「표: 석공 칸」 · 「× 1.3」) — 없으면 이름표 없음 */ + labels?: Record; + /** 흐름 길 — 온 길 · 가는 길 */ + trace?: { back?: DfTraceSet; forward?: DfTraceSet } | null; + /** 끊긴 곳 */ + breaks?: DfBreak[]; +} + +/* ── 알약 · 자리 ────────────────────────────────────────────────────── */ + +/** 알약 — 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: (s: DfTraceSet) => boolean): TraceMark { + if (!trace || (!trace.back && !trace.forward)) return ""; + if (trace.back && pick(trace.back)) return "back"; + if (trace.forward && pick(trace.forward)) return "fwd"; + return "dim"; +} + +/** 굽은 선 가운데 점 — 양 끝 손잡이가 마주 보는 베지어라 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, +}); From b67dcc804c106f833d25eba6a76d9e06de26e4cf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 19:49:24 +0900 Subject: [PATCH 02/14] =?UTF-8?q?feat(M02):=20=ED=9D=90=EB=A6=84=EB=8F=84?= =?UTF-8?q?=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20=E2=80=94=20?= =?UTF-8?q?=EB=84=93=ED=9E=8C=20=ED=83=80=EC=9E=85=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=A4=20=C2=B7=20=EC=84=A0=20=EA=B7=9C=EC=B9=99=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=ED=91=9C(CfLink.rule)=20=C2=B7=20=ED=9D=90=EB=A6=84?= =?UTF-8?q?=20=EA=B8=B8(CfStep)=20=C2=B7=20=EB=81=8A=EA=B8=B4=20=EA=B3=B3(?= =?UTF-8?q?CfBreak)=20=C2=B7=20=EA=B3=A0=EB=A5=B8=20=EC=84=A0=20=C2=B7=20?= =?UTF-8?q?=EC=83=81=EC=9E=90=EB=8A=94=20=ED=9D=90=EB=A6=AC=EA=B2=8C=20?= =?UTF-8?q?=EC=95=88=20=ED=95=A8(PLAN=2035-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DataFlow 전부 검사 초록 · Diagram 639줄 + Parts 248줄 - ORCA 견본(상자 6 · 선 5 · 규칙 넷): 이름표 「표: 석공 칸」「× 1.3」「지반 = 암일 때」「소수 2 반올림」 선 가운데 0.1 안 · 온 길 상자 3 · 선 2 · 알약 5 / 가는 길 상자 2 · 선 2 · 알약 3 / 나머지 흐림(색 + 굵기 3.5 + 점선) · 끊긴 곳 상자 · 선 · 알약 표시 · 누르면 까닭 · 알약 고름 on.select(f1, 알약) · 선 고름 진하게 · 가운데 단추 끌기 판 +60,+30(상자 안 움직임) · 왼쪽 빈 곳 끌기 판 그대로 · Space+끌기 판 −50 · 상자 머리 끌기 on.move · user-select none · 밝음 · 어두움 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z --- M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts | 26 +++++---- .../DataFlow/M02_DF_Diagram_Parts.ts | 55 +++++++++++-------- 2 files changed, 46 insertions(+), 35 deletions(-) diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts index dbcdcf73d..a6ccd0742 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts @@ -14,6 +14,7 @@ import { clampZoom, fitView, KIND_COLOR, + linkLabel, midOf, pillsOf, resolveDrop, @@ -135,7 +136,7 @@ export function renderDiagram( 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.port?.box === b.id && k.port.name === p.name); + 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: @@ -146,7 +147,7 @@ export function renderDiagram( chosen ? "is-chosen" : "", broken ? "is-broken" : "", st.linking && dir === "in" && b.id !== st.linking.box ? "is-target" : "", - ].join(" ") + markOf((s) => !!s.pills?.some((x) => x.box === b.id && x.name === p.name)), + ].join(" ") + markOf((s) => s.some((x) => x.box === b.id && x.port === p.name)), attrs: { type: "button", "data-box": b.id, @@ -165,7 +166,7 @@ export function renderDiagram( 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?.why ?? ""); + else select(b.id, sel, broken?.text ?? ""); }); if (dir === "out") btn.addEventListener("pointerdown", (ev) => dragLink(ev, { box: b.id, name: p.name })); @@ -197,7 +198,7 @@ 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.box === b.id); + const broken = breaks.filter((k) => !k.link && !k.port && k.box === b.id); const head = el("div", { className: "m02-dfdg__head", children: [ @@ -209,7 +210,7 @@ export function renderDiagram( const node = el("div", { className: `m02-dfdg__box is-${b.kind}${st.selected === b.id ? " is-selected" : ""}` + - markOf((s) => !!s.boxes?.includes(b.id)), + (st.selected === b.id ? "" : markOf((s) => s.some((x) => x.box === b.id))), attrs: { "data-box": b.id, tabindex: "0", @@ -236,8 +237,8 @@ export function renderDiagram( }), ...(broken.length ? [ - warn(broken.map((k) => k.why).join(" · "), () => - select(b.id, undefined, broken.map((k) => k.why).join(" · ")), + warn(broken.map((k) => k.text).join(" · "), () => + select(b.id, undefined, broken.map((k) => k.text).join(" · ")), ), ] : []), @@ -349,7 +350,7 @@ export function renderDiagram( ), ); } else if (selLink) { - const rule = labels[selLink.id]; + 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 }), @@ -470,7 +471,8 @@ export function renderDiagram( const strong = st.selected === l.id || (selBox !== null && (l.from.box === selBox.id || l.to.box === selBox.id)); - const mark = markOf((s) => !!s.links?.includes(l.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}`, @@ -480,7 +482,7 @@ export function renderDiagram( 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]; + 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" }); @@ -498,7 +500,7 @@ export function renderDiagram( } const broken = breaks.filter((k) => k.link === l.id); if (broken.length) { - const why = broken.map((k) => k.why).join(" · "); + 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), @@ -516,7 +518,7 @@ export function renderDiagram( } g.addEventListener("click", (ev) => { ev.stopPropagation(); - const why = broken.map((k) => k.why).join(" · "); + const why = broken.map((k) => k.text).join(" · "); select(st.selected === l.id ? null : l.id, undefined, st.selected === l.id ? "" : why); }); } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts b/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts index b618b1a03..587a5c960 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts @@ -13,7 +13,11 @@ import { 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"], @@ -81,21 +85,6 @@ export interface DfPillSel { dir: "in" | "out"; } -/** 흐름 길 한쪽 — 칠할 상자 · 선 · 알약 */ -export interface DfTraceSet { - boxes?: string[]; - links?: string[]; - pills?: { box: string; name: string }[]; -} - -/** 끊긴 곳 — 상자 · 선 · 알약 중 하나 + 까닭 */ -export interface DfBreak { - box?: string; - link?: string; - port?: { box: string; name: string }; - why: string; -} - export interface DiagramOn { /** 상자 · 선 id · 없음 — 알약을 고르면 상자 id 와 알약 */ select(id: string | null, pill?: DfPillSel): void; @@ -114,12 +103,12 @@ export interface DiagramOpts { selected?: string | null; /** 고른 알약 */ selectedPill?: DfPillSel | null; - /** 선 id → 선 규칙 글(「표: 석공 칸」 · 「× 1.3」) — 없으면 이름표 없음 */ + /** 선 id → 선 규칙 글 — 없으면 선의 규칙(CfLink.rule)에서 · 규칙 없으면 이름표 없음 */ labels?: Record; - /** 흐름 길 — 온 길 · 가는 길 */ - trace?: { back?: DfTraceSet; forward?: DfTraceSet } | null; - /** 끊긴 곳 */ - breaks?: DfBreak[]; + /** 흐름 길 — Model traceBack · traceForward 걸음 */ + trace?: { back?: CfStep[]; forward?: CfStep[] } | null; + /** 끊긴 곳 — Eval breaks */ + breaks?: CfBreak[]; } /* ── 알약 · 자리 ────────────────────────────────────────────────────── */ @@ -216,18 +205,38 @@ export const showValue = (v: unknown): string => { return String(v).slice(0, 12); }; -/* ── 흐름 길 ────────────────────────────────────────────────────────── */ +/* ── 흐름 길 · 선 규칙 글 ───────────────────────────────────────────── */ /** 흐름 길 자리 — 온 길 · 가는 길 · 흐림(길이 있는데 안 듦) · 「」(길 없음) */ export type TraceMark = "back" | "fwd" | "dim" | ""; -export function traceOf(trace: DiagramOpts["trace"], pick: (s: DfTraceSet) => boolean): TraceMark { - if (!trace || (!trace.back && !trace.forward)) return ""; +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 function linkLabel(link: CfLink): string { + const r = link.rule; + if (!r) return ""; + try { + return ruleLabel(r); + } catch { + if (r.kind === "lookup") return `표: ${r.col} 칸`; + if (r.kind === "gate") return `${r.from.name} ${r.op} ${r.value}일 때`; + if (r.kind === "scale") + return r.op === "×" || r.op === "÷" + ? `${r.op} ${r.value}` + : `${r.op === "+%" ? "+" : "−"}${r.value}%`; + return `소수 ${r.places} ${r.method}`; + } +} + /** 굽은 선 가운데 점 — 양 끝 손잡이가 마주 보는 베지어라 t = 0.5 가 두 끝의 가운데 */ export const midOf = ( a: { x: number; y: number }, From b76420dc3c6e081cbaf7c3937dc14e37cb07c137 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 19:50:36 +0900 Subject: [PATCH 03/14] =?UTF-8?q?feat(m02-df):=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=ED=8C=90=20=EC=A1=B0=EA=B1=B4=20=EC=B0=BE=EA=B8=B0=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=C2=B7=20=EC=9E=AC=EB=A3=8C=20=EC=9A=94=EC=86=8C=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU --- .../DataFlow/M02_DF_InputPanel_Build.ts | 6 +++++- .../DataFlow/M02_DF_InputPanel_Material.ts | 16 +++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts index f6263d2ce..eb8fcc7bc 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; } 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({ From 745d1641774b4eae3e56612b390b3f177cf5c777 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 19:51:23 +0900 Subject: [PATCH 04/14] =?UTF-8?q?refactor(M02):=20=ED=9D=90=EB=A6=84?= =?UTF-8?q?=EB=8F=84=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20?= =?UTF-8?q?=E2=80=94=20=EC=84=A0=20=EA=B7=9C=EC=B9=99=20=EA=B8=80=EC=9D=84?= =?UTF-8?q?=20Rule.ruleLabel=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=C2=B7=20?= =?UTF-8?q?=EA=B0=80=EC=A0=95=ED=95=9C=20=EB=8C=80=EC=8B=A0=20=EA=B8=80=20?= =?UTF-8?q?=EA=B1=B7=EC=9D=8C(PLAN=2035-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Model · Eval 몸통(03736cba1) 받은 뒤 DataFlow 전부 검사 초록 - ORCA: 「표에서 찾기 (뒷길이 = 뒷길이) → 석공」 · 「× 1.3」 · 「지반 = 암 일 때만」 · 「소수 2자리 반올림」 · 규칙 없는 선 이름표 없음 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z --- .../DataFlow/M02_DF_Diagram_Parts.ts | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts b/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts index 587a5c960..2b9ac51e0 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts @@ -220,22 +220,8 @@ export function traceOf( return "dim"; } -/** 선 위 규칙 글 — Rule 의 ruleLabel · 준비 전이면 같은 꼴로 */ -export function linkLabel(link: CfLink): string { - const r = link.rule; - if (!r) return ""; - try { - return ruleLabel(r); - } catch { - if (r.kind === "lookup") return `표: ${r.col} 칸`; - if (r.kind === "gate") return `${r.from.name} ${r.op} ${r.value}일 때`; - if (r.kind === "scale") - return r.op === "×" || r.op === "÷" - ? `${r.op} ${r.value}` - : `${r.op === "+%" ? "+" : "−"}${r.value}%`; - return `소수 ${r.places} ${r.method}`; - } -} +/** 선 위 규칙 글 — Rule 의 ruleLabel · 규칙 없으면 「」 */ +export const linkLabel = (link: CfLink): string => (link.rule ? ruleLabel(link.rule) : ""); /** 굽은 선 가운데 점 — 양 끝 손잡이가 마주 보는 베지어라 t = 0.5 가 두 끝의 가운데 */ export const midOf = ( From 083f320ac49d5d073087961d3d1b086268cf1f98 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 19:52:01 +0900 Subject: [PATCH 05/14] =?UTF-8?q?fix(M02-=ED=9D=90=EB=A6=84=EB=8F=84):=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EB=82=B4=EC=9A=A9=20=ED=8C=90=EC=9D=84=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EB=AA=B8=ED=86=B5=EC=97=90=20=EC=9D=B4?= =?UTF-8?q?=EC=9D=8C=20-=20=EB=B9=88=20=ED=95=A8=EC=88=98=20=EB=8C=80?= =?UTF-8?q?=EC=8B=A0=20traceBack=C2=B7breaks=C2=B7arrived=20=EB=A5=BC=20?= =?UTF-8?q?=EC=94=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c --- M02_MasterTemplete/DataFlow/M02_DF_Detail.ts | 238 +++++------------- .../DataFlow/M02_DF_LinkPanel.ts | 19 +- .../DataFlow/M02_DF_PillPanel.ts | 24 +- 3 files changed, 78 insertions(+), 203 deletions(-) diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Detail.ts b/M02_MasterTemplete/DataFlow/M02_DF_Detail.ts index 6ab1911b7..30b1312f8 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Detail.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Detail.ts @@ -1,14 +1,22 @@ /* ============================================================================= * M02_DF_Detail.ts - * 상세 내용 판(선 · 알약) 공용 — 값 읽기 · 규칙 미리 보기 · 겹침/안 맞음 · 흐름 줄 · 끊김 까닭 · DOM 없음 - * 모델의 traceBack · traceForward · breaks · applyRule · ruleLabel 이 아직 준비 중이면 같은 뜻의 작은 셈으로 메움 + * 상세 내용 판(선 · 알약) 공용 — 값 읽기 · 규칙 미리 보기 · 끊김 글 · 흐름 줄 · DOM 없음 + * 셈은 모델 것 그대로(Eval 의 evalLogic · breaks · Model 의 traceBack · traceForward · Rule 의 ruleLabel) * 글 [한, 영] 짝은 아래 TEXT * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { breaks, evalLogic, type CfEvalResult, type CfValue, type CfValues } from "./M02_DF_Eval"; import { - portsIn, + breaks, + evalLogic, + type CfBreak, + type CfEvalOptions, + type CfEvalResult, + type CfValue, + type CfValues, +} from "./M02_DF_Eval"; +import { + portDir, portsOut, traceBack, traceForward, @@ -17,26 +25,15 @@ import { type CfLink, type CfLogic, type CfPort, - type CfRound, type CfRule, type CfStep, } from "./M02_DF_Model"; -import { applyRule, ruleLabel } from "./M02_DF_Rule"; +import { ruleLabel } from "./M02_DF_Rule"; const TEXT = { Table: ["표 {n}줄", "table, {n} rows"], Empty: ["값 없음", "no value"], - LabelLookup: ["표에서 「{col}」 찾기", "Look up “{col}” in the table"], - LabelGate: ["「{pill}」 {op} {value} 일 때만 보냄", "Send only when “{pill}” {op} {value}"], - LabelScale: ["값 {op} {value}", "value {op} {value}"], - LabelRound: ["소수 {places}자리 {method}", "{places} decimals, {method}"], NoRule: ["규칙 없음 — 값이 그대로 감", "No rule — the value goes as is"], - Blocked: ["통과 안 됨", "does not pass"], - NoRowLater: ["표에서 맞는 줄은 계산 뒤에 보임", "The matching row shows after calculation"], - EmptyIn: ["빈 받는 자리 — 선이 없음", "Empty receiving spot — no line"], - NoCell: ["받은 알약을 칸에 놓지 않음", "The received pill is not placed in a cell"], - Dead: ["아직 어디로도 안 감", "Goes nowhere yet"], - NoValue: ["값이 아직 없음", "No value yet"], } as const; export const td = (key: keyof typeof TEXT, fill: Record = {}): string => @@ -48,16 +45,22 @@ export const td = (key: keyof typeof TEXT, fill: Record export const GATE_OPS: CfGateOp[] = ["=", "≠", "≥", "≤", ">", "<"]; export const SCALE_OPS: ("×" | "÷" | "+%" | "-%")[] = ["×", "÷", "+%", "-%"]; +/** 셈에 넘길 것 — 값 묶음 · 다른 로직들 */ +export interface DetailOptions { + values?: CfValues; + logics?: CfEvalOptions["logics"]; +} + export const boxOf = (lg: CfLogic, id: string): CfBox | undefined => lg.boxes.find((b) => b.id === id); export const portText = (lg: CfLogic, p: CfPort): string => `${boxOf(lg, p.box)?.name ?? "?"} › ${p.name}`; -/** 계산 한 번 — 못 하면 null */ -export function evalSafe(lg: CfLogic, values?: CfValues): CfEvalResult | null { +/** 계산 한 번 — 한창 만드는 중 막히면 null */ +export function evalSafe(lg: CfLogic, o: DetailOptions = {}): CfEvalResult | null { try { - return evalLogic(lg, values); + return evalLogic(lg, o.values, { logics: o.logics }); } catch { return null; } @@ -79,176 +82,55 @@ export function unitOf(lg: CfLogic, p: CfPort): string { } /** 알약이 나가는(out) 쪽인가 */ -export const isOut = (box: CfBox, name: string): boolean => - portsOut(box).some((p) => p.name === name); +export const isOut = (box: CfBox, name: string): boolean => portDir(box, name) === "out"; -/* ── 규칙 ─────────────────────────────────────────────────────────────── */ +/** 선 위 규칙 글 */ +export const labelOf = (rule: CfRule | undefined): string => + rule ? ruleLabel(rule) : td("NoRule"); -const num = (v: unknown): number | null => { - const n = typeof v === "number" ? v : Number(v); - return Number.isFinite(n) && v !== "" && v !== null ? n : null; -}; - -/** 조건 통과 — 숫자면 숫자끼리 · 아니면 글끼리 */ -export function gatePass(op: CfGateOp, v: unknown, value: number | string): boolean { - const a = num(v); - const b = num(value); - const [x, y] = a !== null && b !== null ? [a, b] : [String(v ?? ""), String(value)]; - switch (op) { - case "=": - return x === y; - case "≠": - return x !== y; - case "≥": - return x >= y; - case "≤": - return x <= y; - case ">": - return x > y; - default: - return x < y; - } +/** 지나가기 전 값 → 지난 뒤 값 — 지난 뒤 값은 셈이 받는 알약에 실제로 들여보낸 것 · 못 들어갔으면 끊긴 까닭 */ +export function preview(link: CfLink, ev: CfEvalResult | null): { before: string; after: string } { + const before = showValue(valueOf(ev, link.from)); + if (!link.rule) return { before, after: before }; + const arrived = ev?.arrived?.[link.to.box]?.[link.to.name]; + if (arrived !== undefined && arrived !== null) return { before, after: showValue(arrived) }; + const why = ev?.breaks?.find((b) => b.link === link.id)?.text; + return { before, after: why ?? td("Empty") }; } -export function roundValue(r: CfRound, v: number): number { - const k = 10 ** r.places; - const f = r.method === "올림" ? Math.ceil : r.method === "버림" ? Math.floor : Math.round; - return f(v * k) / k; +/** 이 선에서 알릴 것 — 이 선이 막힌 까닭 · 같은 알약에서 나간 조건 선의 겹침 · 안 맞음 */ +export function linkNotes(link: CfLink, ev: CfEvalResult | null): string[] { + return (ev?.breaks ?? []) + .filter( + (b) => + b.link === link.id || + ((b.kind === "조건 겹침" || b.kind === "조건 안 맞음") && + b.box === link.from.box && + b.port === link.from.name), + ) + .map((b) => b.text); } -/** 선 위 규칙 글 — 모델이 준비되면 그것, 아니면 같은 뜻의 글 */ -export function labelOf(lg: CfLogic, rule: CfRule | undefined): string { - if (!rule) return td("NoRule"); - try { - return ruleLabel(rule); - } catch { - if (rule.kind === "lookup") return td("LabelLookup", { col: rule.col }); - if (rule.kind === "gate") - return td("LabelGate", { pill: portText(lg, rule.from), op: rule.op, value: rule.value }); - if (rule.kind === "scale") return td("LabelScale", { op: rule.op, value: rule.value }); - return td("LabelRound", { places: rule.places, method: rule.method }); - } -} - -/** 지나가기 전 값 → 지난 뒤 값 미리 보기 */ -export function preview( - link: CfLink, - rule: CfRule | undefined, - ev: CfEvalResult | null, -): { before: string; after: string } { - const raw = valueOf(ev, link.from); - const before = showValue(raw); - if (!rule) return { before, after: before }; - const conds: Record = {}; - if (rule.kind === "lookup") for (const c of rule.conds) conds[c.col] = valueOf(ev, c.from); - try { - const r = applyRule(rule, raw, conds); - return { - before, - after: r.value === null ? (r.why ?? td("Empty")) : showValue(r.value as CfValue), - }; - } catch { - const n = num(raw); - if (rule.kind === "gate") - return { - before, - after: gatePass(rule.op, valueOf(ev, rule.from), rule.value) ? before : td("Blocked"), - }; - if (rule.kind === "lookup" || n === null) return { before, after: td("NoRowLater") }; - if (rule.kind === "round") return { before, after: showValue(roundValue(rule, n)) }; - const v = rule.value; - const out = - rule.op === "×" - ? n * v - : rule.op === "÷" - ? v - ? n / v - : NaN - : rule.op === "+%" - ? n * (1 + v / 100) - : n * (1 - v / 100); - return { before, after: Number.isFinite(out) ? showValue(out) : td("Empty") }; - } -} - -/** 같은 알약에서 나간 조건 선 — 지금 값으로 통과하는 수 */ -export function gateReport( - lg: CfLogic, - link: CfLink, - ev: CfEvalResult | null, -): { total: number; pass: string[] } { - const same = lg.links.filter( - (l) => - l.from.box === link.from.box && l.from.name === link.from.name && l.rule?.kind === "gate", - ); - const pass = same - .filter((l) => { - const r = l.rule as Extract; - return gatePass(r.op, valueOf(ev, r.from), r.value); - }) - .map((l) => l.id); - return { total: same.length, pass }; -} - -/* ── 흐름 · 끊김 ─────────────────────────────────────────────────────── */ - -/** 거슬러 오르는 길(dir "in" = 온 길) · 따라가는 길(dir "out") — 모델 것, 없으면 작은 셈 */ +/** 온 길(dir "in") · 가는 길(dir "out") — 모델 것에서 자기 자신 줄은 뺌 */ export function trace(lg: CfLogic, p: CfPort, dir: "in" | "out"): CfStep[] { - try { - return dir === "in" ? traceBack(lg, p) : traceForward(lg, p); - } catch { - const out: CfStep[] = []; - const seen = new Set(); - const walk = (port: CfPort, back: boolean): void => { - const key = `${port.box}/${port.name}`; - if (seen.has(key)) return; - seen.add(key); - const box = boxOf(lg, port.box); - if (!box) return; - if (back) { - const into = lg.links.filter( - (l) => l.to.box === port.box && (isOut(box, port.name) || l.to.name === port.name), - ); - for (const l of into) { - out.push({ box: l.from.box, port: l.from.name, dir: "out", link: l.id, rule: l.rule }); - walk(l.from, true); - } - } else { - const outs = lg.links.filter((l) => l.from.box === port.box && l.from.name === port.name); - for (const l of outs) { - out.push({ box: l.to.box, port: l.to.name, dir: "in", link: l.id, rule: l.rule }); - const next = boxOf(lg, l.to.box); - for (const o of next ? portsOut(next) : []) walk({ box: next!.id, name: o.name }, false); - } - } - }; - walk(p, dir === "in"); - return out; - } + const steps = dir === "in" ? traceBack(lg, p) : traceForward(lg, p); + return steps.filter((s) => !(s.box === p.box && s.port === p.name)); } /** 그 상자(알약)에서 끊긴 곳 — 쉬운 말 */ -export function breakLines(lg: CfLogic, boxId: string, name?: string): string[] { +export function breakLines( + lg: CfLogic, + boxId: string, + name?: string, + o: DetailOptions = {}, +): string[] { + let list: CfBreak[] = []; try { - return breaks(lg) - .filter((b) => b.box === boxId && (!name || !b.port || b.port === name)) - .map((b) => b.text); + list = breaks(lg, o.values, { logics: o.logics }); } catch { - // 모델의 끊긴 곳 목록이 준비되기 전 — 같은 뜻의 작은 셈 + // 만드는 중 셈이 막히면 끊긴 곳 없음으로 둠 } - const box = boxOf(lg, boxId); - if (!box) return []; - const out: string[] = []; - for (const p of portsIn(box)) { - if (name && p.name !== name) continue; - if (!lg.links.some((l) => l.to.box === boxId && l.to.name === p.name)) out.push(td("EmptyIn")); - else if (box.func?.inputs.some((i) => i.port === p.name && !i.cell)) out.push(td("NoCell")); - } - for (const p of portsOut(box)) { - if (name && p.name !== name) continue; - if (!lg.links.some((l) => l.from.box === boxId && l.from.name === p.name)) out.push(td("Dead")); - } - return out; + return list + .filter((b) => b.box === boxId && (!name || !b.port || b.port === name)) + .map((b) => b.text); } - -export const noValue = (): string => td("NoValue"); diff --git a/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.ts index 32f1d9fb9..1d7c4043c 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.ts @@ -8,7 +8,6 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, el } from "@ui/ui_template_elements"; -import type { CfValues } from "./M02_DF_Eval"; import { portsOut, reaches, @@ -22,12 +21,13 @@ import { GATE_OPS, SCALE_OPS, evalSafe, - gateReport, labelOf, + linkNotes, portText, preview, unitOf, valueOf, + type DetailOptions, } from "./M02_DF_Detail"; import { suggestPlaces } from "./M02_DF_UnitPick"; import "./M02_DF_LinkPanel.css"; @@ -317,12 +317,12 @@ export function renderLinkPanel( lg: CfLogic, linkId: string, on: { change(lg: CfLogic): void }, - opts: { values?: CfValues; readOnly?: boolean } = {}, + opts: DetailOptions & { readOnly?: boolean } = {}, ): void { const link = lg.links.find((l) => l.id === linkId); host.classList.add("m02-dflink"); if (!link) return host.replaceChildren(); - const ev = evalSafe(lg, opts.values); + const ev = evalSafe(lg, opts); const table = valueOf(ev, link.from); const headers = Array.isArray(table) && Array.isArray(table[0]) @@ -358,7 +358,7 @@ export function renderLinkPanel( if (rule?.kind === "scale") parts.push(...scaleEditor(ctx, rule)); if (rule?.kind === "round") parts.push(...roundEditor(ctx, rule, unitOf(lg, link.from))); - const pv = preview(link, rule, ev); + const pv = preview(link, ev); parts.push( row( el("span", { className: "m02-dflink-label", text: tk("Before") }), @@ -369,16 +369,11 @@ export function renderLinkPanel( ), row( el("span", { className: "m02-dflink-label", text: tk("Label") }), - el("span", { text: labelOf(lg, rule) }), + el("span", { text: labelOf(rule) }), ), ); - const warns: string[] = []; - if (rule?.kind === "gate") { - const rep = gateReport(lg, link, ev); - if (rep.pass.length > 1) warns.push(tk("WarnBoth", rep.pass.length)); - if (rep.total > 0 && rep.pass.length === 0) warns.push(tk("WarnNone", rep.total)); - } + const warns: string[] = linkNotes(link, ev); if (rule?.kind === "lookup") { if (!rule.conds.length) warns.push(tk("WarnLookup")); if (!rule.col) warns.push(tk("WarnTake")); diff --git a/M02_MasterTemplete/DataFlow/M02_DF_PillPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_PillPanel.ts index 3c92bea06..dcd3a4aa3 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_PillPanel.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_PillPanel.ts @@ -7,7 +7,6 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, el } from "@ui/ui_template_elements"; -import type { CfValues } from "./M02_DF_Eval"; import type { CfLogic, CfStep } from "./M02_DF_Model"; import { boxOf, @@ -20,6 +19,7 @@ import { trace, unitOf, valueOf, + type DetailOptions, } from "./M02_DF_Detail"; import "./M02_DF_LinkPanel.css"; @@ -50,6 +50,7 @@ const line = (label: string, ...value: (HTMLElement | string)[]): HTMLElement => function steps( lg: CfLogic, list: CfStep[], + arrow: string, empty: string, onBox?: (id: string) => void, ): HTMLElement { @@ -58,7 +59,7 @@ function steps( className: "m02-dfpill-list", children: list.map((s) => { const parts: (HTMLElement | string)[] = [ - s.dir === "out" ? "◀" : "▶", + arrow, onBox ? createButton({ label: portText(lg, { box: s.box, name: s.port }), @@ -68,8 +69,7 @@ function steps( }) : portText(lg, { box: s.box, name: s.port }), ]; - if (s.rule) - parts.push(el("span", { className: "m02-dfpill-rule", text: labelOf(lg, s.rule) })); + if (s.rule) parts.push(el("span", { className: "m02-dfpill-rule", text: labelOf(s.rule) })); return el("li", { className: "m02-dfpill-step", children: parts }); }), }); @@ -82,20 +82,18 @@ export function renderPillPanel( boxId: string, port: string, on: { pick?(boxId: string): void } = {}, - opts: { values?: CfValues } = {}, + opts: DetailOptions = {}, ): void { host.classList.add("m02-dfpill"); const box = boxOf(lg, boxId); if (!box) return host.replaceChildren(); - const ev = evalSafe(lg, opts.values); + const ev = evalSafe(lg, opts); const out = isOut(box, port); - // 받는 알약의 값 = 그 자리로 들어오는 선의 보낸 알약 값(규칙 지나기 전) + // 받는 알약의 값 = 선 규칙을 지나 실제로 들어온 값(없으면 보낸 알약 값) const incoming = lg.links.find((l) => l.to.box === boxId && l.to.name === port); const value = out ? valueOf(ev, { box: boxId, name: port }) - : incoming - ? valueOf(ev, incoming.from) - : undefined; + : (ev?.arrived?.[boxId]?.[port] ?? (incoming ? valueOf(ev, incoming.from) : undefined)); const unit = out ? unitOf(lg, { box: boxId, name: port }) : incoming @@ -103,7 +101,7 @@ export function renderPillPanel( : box.unit; const back = trace(lg, { box: boxId, name: port }, "in"); const forward = trace(lg, { box: boxId, name: port }, "out"); - const breaks = breakLines(lg, boxId, port); + const breaks = breakLines(lg, boxId, port, opts); host.replaceChildren( el("div", { className: "m02-dflink-head", @@ -118,13 +116,13 @@ export function renderPillPanel( el("div", { children: [ el("div", { className: "m02-dflink-label", text: tp("From") }), - steps(lg, back, tp("Start"), on.pick), + steps(lg, back, "◀", tp("Start"), on.pick), ], }), el("div", { children: [ el("div", { className: "m02-dflink-label", text: tp("To") }), - steps(lg, forward, tp("End"), on.pick), + steps(lg, forward, "▶", tp("End"), on.pick), ], }), el("div", { From 296255e35758c0bc7c8e5cff6edd2c7a3cdd2887 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 19:52:25 +0900 Subject: [PATCH 06/14] =?UTF-8?q?feat(m02-df):=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=B9=B8=EC=9D=84=20Model=20designInputs=20=C2=B7=20Eval=20bre?= =?UTF-8?q?aks=20=EB=A1=9C=20=EC=9D=B4=EC=9D=8C=20=C2=B7=20=EC=B6=9C?= =?UTF-8?q?=EB=A0=A5=20=EB=8B=A8=EC=9C=84=20=EA=B3=A0=EB=A5=B4=EA=B8=B0(Un?= =?UTF-8?q?itPick)=20=C2=B7=20=EB=8B=A8=EC=9C=84=20=ED=91=9C=EC=A4=80=20?= =?UTF-8?q?=EC=86=8C=EC=88=98=EC=9E=90=EB=A6=AC=20=EC=A0=9C=EC=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr --- .../DataFlow/M02_DF_OutputPanel.ts | 33 +++-- M02_MasterTemplete/DataFlow/M02_DF_Summary.ts | 6 +- M02_MasterTemplete/DataFlow/M02_DF_Verify.ts | 128 +++++++----------- 3 files changed, 79 insertions(+), 88 deletions(-) diff --git a/M02_MasterTemplete/DataFlow/M02_DF_OutputPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_OutputPanel.ts index 70d4f4537..bef75f6cb 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_OutputPanel.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_OutputPanel.ts @@ -11,6 +11,7 @@ import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money" import { evalLogic, type CfEvalResult, type CfValue } from "./M02_DF_Eval"; import { portsOut, type CfBox, type CfCost, type CfLogic, type CfRound } from "./M02_DF_Model"; import { defaultItemRound, roundText } from "./M02_DF_Round"; +import { createUnitPick, suggestPlaces } from "./M02_DF_UnitPick"; import "./M02_DF_OutputPanel.css"; const TEXT = { @@ -22,7 +23,6 @@ const TEXT = { Expense: ["경비", "Expense"], Plain: ["값(비용 아님)", "Value (not a cost)"], Unit: ["단위", "Unit"], - UnitHint: ["예: 원 · ㎥ · 시간 · -", "e.g. KRW · ㎥ · h · -"], Got: ["받은 값", "Received value"], From: ["어디서", "From"], Value: ["값", "Value"], @@ -39,6 +39,8 @@ const TEXT = { RoundDefault: ["끝수 기본", "Default rounding"], RoundStd: ["일위대가표 금액란", "unit-price sheet amount column"], RoundNone: ["끝수 없음(값 그대로)", "No rounding (value as is)"], + Suggest: ["단위 표준 제안", "Unit standard suggests"], + Apply: ["적용", "Apply"], RoundReset: ["기본으로", "Back to default"], RoundUser: ["고른 끝수", "Chosen rounding"], PlainHint: [ @@ -165,13 +167,7 @@ export function renderOutputPanel( })), ), }); - const unitBox = el("input", { - className: "m02-dfout__input", - attrs: { type: "text", placeholder: to("UnitHint"), "aria-label": to("Unit") }, - }); - unitBox.value = box.output?.unit ?? box.unit; - unitBox.addEventListener("change", () => { - const next = unitBox.value.trim() || "-"; + const setUnit = (next: string): void => emit(() => withBox(lg, box.id, (b) => ({ ...b, @@ -179,7 +175,27 @@ export function renderOutputPanel( output: { ...b.output, cost: b.output?.cost ?? "노무비", unit: next }, })), ); + const unitBox = createUnitPick({ + value: box.output?.unit ?? box.unit, + ariaLabel: to("Unit"), + extra: lg.resultUnit.includes("/") ? [lg.resultUnit.split("/")[0]] : [], + onPick: setUnit, }); + /** 단위 표준의 소수자리 제안(주 ① 반올림) — 값(비용 아님)이고 끝수를 안 정했을 때 */ + const suggestNote = el("span", { className: "m02-dfout__muted" }); + if (cost === "값" && !box.output?.round) + void suggestPlaces(box.output?.unit ?? box.unit).then((p) => { + if (p === null) return; + suggestNote.textContent = `${to("Suggest")}: ${to("RoundPlaces")} ${p} · 반올림`; + suggestNote.append( + createButton({ + label: to("Apply"), + size: "sm", + variant: "ghost", + onClick: () => setRound({ places: p, method: "반올림" }), + }), + ); + }); const field = (label: string, control: HTMLElement): HTMLElement => el("label", { className: "m02-dfout__field", @@ -253,6 +269,7 @@ export function renderOutputPanel( ], }), roundNote, + suggestNote, ], }); diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Summary.ts b/M02_MasterTemplete/DataFlow/M02_DF_Summary.ts index ed996f5cd..19a2754ac 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Summary.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Summary.ts @@ -9,7 +9,7 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { el } from "@ui/ui_template_elements"; import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money"; -import type { CfEvalResult, CfValues } from "./M02_DF_Eval"; +import type { CfEvalOptions, CfEvalResult, CfValues } from "./M02_DF_Eval"; import type { CfBox, CfLogic, CfRound } from "./M02_DF_Model"; import { showValue } from "./M02_DF_OutputPanel"; import { defaultTotalRound, roundBy, roundText } from "./M02_DF_Round"; @@ -55,7 +55,7 @@ export function renderSummary( lg: CfLogic, result: CfEvalResult, on?: { change(lg: CfLogic): void; select?(sel: CfSel): void }, - opts?: { values?: CfValues }, + opts?: { values?: CfValues; logics?: CfEvalOptions["logics"] }, ): void { const money = !lg.resultUnit || lg.resultUnit.startsWith("원"); const perUnit = lg.resultUnit.split("/").slice(1).join("/"); @@ -131,7 +131,7 @@ export function renderSummary( lg, result, { change: (next) => on?.change(next), select: on?.select }, - { values: opts?.values }, + { values: opts?.values, logics: opts?.logics }, ); const plainValues = Object.entries(cost.값들); host.replaceChildren( diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Verify.ts b/M02_MasterTemplete/DataFlow/M02_DF_Verify.ts index 9c0f850a7..8d7aa57f6 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Verify.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Verify.ts @@ -9,10 +9,11 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, createSelectField, el } from "@ui/ui_template_elements"; import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money"; -import { breaks, type CfBreak, type CfEvalResult, type CfValues } from "./M02_DF_Eval"; +import { breaks, type CfEvalOptions, type CfEvalResult, type CfValues } from "./M02_DF_Eval"; import { findMaterials, materialOf, priceOf, type CfMat } from "./M02_DF_Material"; import { - portsIn, + designInputs, + elementPort, type CfBox, type CfElementRef, type CfLogic, @@ -54,8 +55,6 @@ const TEXT = { "이어 주지 않아서 그 로직의 예시값을 써요", "Not linked — the logic's own sample is used", ], - NoLine: ["「{box}」 의 받는 자리 「{pill}」 에 선이 없어요", "“{pill}” of “{box}” has no line"], - NoOutput: ["출력 상자가 아직 없어요", "There is no output box yet"], } as const; type TextKey = keyof typeof TEXT; @@ -78,28 +77,6 @@ export interface CfVerifyOn { const PAGE = 10; -/** 끊긴 곳 — Model 쪽 `breaks` 가 차면 그것 · 아직이면 빈 받는 자리와 계산 오류만 */ -function brokenSpots(lg: CfLogic, result: CfEvalResult, values: CfValues): CfBreak[] { - try { - return breaks(lg, values); - } catch { - const out: CfBreak[] = []; - if (!lg.boxes.some((b) => b.kind === "output")) - out.push({ kind: "오류", box: "", text: tv("NoOutput") }); - for (const b of lg.boxes) - for (const p of portsIn(b)) - if (!lg.links.some((l) => l.to.box === b.id && l.to.name === p.name)) - out.push({ - kind: "빈 받는 자리", - box: b.id, - port: p.name, - text: tv("NoLine", { box: b.name, pill: p.name }), - }); - for (const e of result.errors) out.push({ kind: "오류", box: "", text: e }); - return out; - } -} - /** 재료 요소 하나 고치기 — 나머지는 그대로 */ function withElement( lg: CfLogic, @@ -285,7 +262,7 @@ function slotRow(box: CfBox, e: CfElementRef, on: CfVerifyOn, lg: CfLogic): HTML children: [ el("div", { className: "m02-dfver__line", - children: [el("strong", { text: e.slot ?? e.name }), now, price, toggle], + children: [el("strong", { text: elementPort(e) }), now, price, toggle], }), panel, ], @@ -325,7 +302,7 @@ function ruleRow(e: CfElementRef, lg: CfLogic, result: CfEvalResult): HTMLElemen children: [ el("div", { className: "m02-dfver__line", - children: [el("strong", { text: e.slot ?? e.name }), state, note], + children: [el("strong", { text: elementPort(e) }), state, note], }), el("span", { className: "m02-dfver__muted", @@ -341,7 +318,7 @@ export function renderVerify( lg: CfLogic, result: CfEvalResult, on: CfVerifyOn, - opts: { values?: CfValues } = {}, + opts: { values?: CfValues; logics?: CfEvalOptions["logics"] } = {}, ): void { const msg = el("p", { className: "m02-dfver__msg" }); const safe = (make: () => CfLogic): void => { @@ -354,7 +331,7 @@ export function renderVerify( }; /* 끊긴 곳 */ - const spots = brokenSpots(lg, result, opts.values ?? {}); + const spots = breaks(lg, opts.values ?? {}, { logics: opts.logics }); const spotBlock = spots.length ? [ el("div", { @@ -383,59 +360,56 @@ export function renderVerify( ] : []; - /* 설계 때 정할 것 */ + /* 설계 때 정할 것 — Model `designInputs`(변수 · 재료 자리 · 다른 로직) + 재료 조건 자리 */ + const designs = designInputs(lg); + const boxOf = (id: string): CfBox | undefined => lg.boxes.find((b) => b.id === id); + /** 변수 예시값을 그 상자(입력 · 함수)의 변수에 되적음 */ + const putExample = (box: string, name: string, value: number | string): CfLogic => ({ + ...lg, + boxes: lg.boxes.map((x) => { + if (x.id !== box) return x; + const set = (list: CfVariable[]): CfVariable[] => + list.map((y) => (y.name === name ? { ...y, example: value } : y)); + return { + ...x, + ...(x.input ? { input: { ...x.input, variables: set(x.input.variables) } } : {}), + ...(x.func ? { func: { ...x.func, variables: set(x.func.variables) } } : {}), + }; + }), + }); + const vars = designs.flatMap((d) => + d.kind === "변수" && d.variable + ? [sampleField(d.variable, (value) => safe(() => putExample(d.box, d.port, value)), msg)] + : [], + ); + const slots = designs.flatMap((d) => { + const box = boxOf(d.box); + return d.kind === "재료 자리" && d.element && box + ? [slotRow(box, d.element, { ...on, change: (n) => safe(() => n) }, lg)] + : []; + }); const inputs = lg.boxes.filter((b) => b.kind === "input" && b.input); - const vars = inputs.flatMap((b) => - (b.input?.variables ?? []).map((v, i) => - sampleField( - v, - (value) => - safe(() => ({ - ...lg, - boxes: lg.boxes.map((x) => - x.id === b.id && x.input - ? { - ...x, - input: { - ...x.input, - variables: x.input.variables.map((y, j) => - j === i ? { ...y, example: value } : y, - ), - }, - } - : x, - ), - })), - msg, - ), - ), - ); - const slots = inputs.flatMap((b) => - (b.input?.elements ?? []) - .filter((e) => e.mode === "choice") - .map((e) => slotRow(b, e, { ...on, change: (n) => safe(() => n) }, lg)), - ); const rules = inputs.flatMap((b) => (b.input?.elements ?? []) .filter((e) => e.mode === "rule" && e.rule) .map((e) => ruleRow(e, lg, result)), ); - const logics = inputs.flatMap((b) => - (b.input?.logic?.designs ?? []) - .filter((d) => !lg.links.some((l) => l.to.box === b.id && l.to.name === d.name)) - .map((d) => - el("li", { - children: [ - createButton({ - label: `${b.name} · ${d.name}${d.unit && d.unit !== "-" ? ` (${d.unit})` : ""} — ${tv("OtherLogic")}`, - variant: "ghost", - size: "sm", - disabled: !on.select, - onClick: () => on.select?.({ box: b.id, port: d.name }), - }), - ], - }), - ), + const logics = designs.flatMap((d) => + d.kind === "다른 로직" + ? [ + el("li", { + children: [ + createButton({ + label: `${boxOf(d.box)?.name ?? ""} · ${d.port}${d.unit && d.unit !== "-" ? ` (${d.unit})` : ""} — ${tv("OtherLogic")}`, + variant: "ghost", + size: "sm", + disabled: !on.select, + onClick: () => on.select?.({ box: d.box, port: d.port }), + }), + ], + }), + ] + : [], ); const group = (title: TextKey, body: HTMLElement[]): HTMLElement[] => body.length From 646799e902388f4ac143e533b4069a8b7e3d0303 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 19:52:52 +0900 Subject: [PATCH 07/14] =?UTF-8?q?fix(m02-df):=20=EB=8B=A4=EB=A5=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=9E=85=EB=A0=A5=EC=9D=84=20=EB=B9=BC?= =?UTF-8?q?=EB=A9=B4=20=EB=B0=9B=EB=8A=94=20=EC=95=8C=EC=95=BD=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=93=A4=EC=96=B4=EC=98=A4=EB=8D=98=20=EC=84=A0?= =?UTF-8?q?=EB=8F=84=20=EC=A7=80=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU --- M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts | 4 ++-- resources/tester/test_m02_df_inputpanel_material.py | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts index eb8fcc7bc..541fd358c 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts @@ -259,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, @@ -270,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/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 # 다른 로직 상자 단위는 안 건드림 From dad45e997be7b59b5e736bfcec629a8b7b09d5be Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 19:52:55 +0900 Subject: [PATCH 08/14] =?UTF-8?q?feat(m02-df):=20=EB=8B=A4=EB=A5=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=9E=85=EB=A0=A5=20=EB=B0=9B=EB=8A=94=20?= =?UTF-8?q?=EC=95=8C=EC=95=BD=20=EC=98=88=EC=8B=9C=EA=B0=92=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CfLogicRef.examples(받는 알약 이름 → 값) · Eval 값 차례 = 이어진 선 > 넘긴 값 > 상자 예시값 > 그 로직 자기 예시값 - CfDesign.example — 변수 = variable.example · 다른 로직 = examples(없으면 null) - 시험 23개 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk --- M02_MasterTemplete/DataFlow/M02_DF_Eval.ts | 3 ++- M02_MasterTemplete/DataFlow/M02_DF_Model.ts | 21 +++++++++++++++++++-- resources/tester/test_m02_df_model.py | 17 +++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) 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_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_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] From df735e99a8307f4c229c6b114c3d86a36373ab87 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 19:55:42 +0900 Subject: [PATCH 09/14] =?UTF-8?q?feat(m02-df):=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=B9=B8=EC=97=90=EC=84=9C=20=EB=8B=A4=EB=A5=B8=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=B0=9B=EB=8A=94=20=EC=95=8C=EC=95=BD=20=EC=98=88?= =?UTF-8?q?=EC=8B=9C=EA=B0=92(logic.examples)=EC=9D=84=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr --- M02_MasterTemplete/DataFlow/M02_DF_Verify.ts | 55 ++++++++++++++------ 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Verify.ts b/M02_MasterTemplete/DataFlow/M02_DF_Verify.ts index 8d7aa57f6..c02658d53 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Verify.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Verify.ts @@ -29,7 +29,7 @@ const TEXT = { Vars: ["변수 예시값", "Variable samples"], Slots: ["재료 선택 자리", "Material choice"], Rules: ["재료 조건 자리", "Material by condition"], - Logics: ["이어지지 않은 다른 로직 값", "Unlinked values of other logics"], + Logics: ["이어지지 않은 다른 로직 값(예시값)", "Unlinked values of other logics (samples)"], Choose: ["고르기", "Choose"], Close: ["닫기", "Close"], Find: ["찾기", "Find"], @@ -51,6 +51,7 @@ const TEXT = { Bad: ["숫자를 넣어 주세요", "Enter a number"], Range: ["{a} ∼ {b} 사이", "between {a} and {b}"], Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"], + OwnSample: ["비우면 그 로직 예시값", "Empty = the logic's own sample"], OtherLogic: [ "이어 주지 않아서 그 로직의 예시값을 써요", "Not linked — the logic's own sample is used", @@ -394,23 +395,43 @@ export function renderVerify( .filter((e) => e.mode === "rule" && e.rule) .map((e) => ruleRow(e, lg, result)), ); - const logics = designs.flatMap((d) => - d.kind === "다른 로직" - ? [ - el("li", { - children: [ - createButton({ - label: `${boxOf(d.box)?.name ?? ""} · ${d.port}${d.unit && d.unit !== "-" ? ` (${d.unit})` : ""} — ${tv("OtherLogic")}`, - variant: "ghost", - size: "sm", - disabled: !on.select, - onClick: () => on.select?.({ box: d.box, port: d.port }), - }), - ], + /** 다른 로직의 받는 알약 — 예시값을 적으면 logic.examples · 비우면 그 로직 자기 예시값 */ + const putLogicExample = (box: string, port: string, raw: string): CfLogic => ({ + ...lg, + boxes: lg.boxes.map((x) => { + if (x.id !== box || !x.input?.logic) return x; + const examples = { ...x.input.logic.examples }; + const text = raw.trim(); + if (text === "") delete examples[port]; + else examples[port] = Number.isNaN(Number(text)) ? text : Number(text); + return { ...x, input: { ...x.input, logic: { ...x.input.logic, examples } } }; + }), + }); + const logics = designs.flatMap((d) => { + if (d.kind !== "다른 로직") return []; + const input = el("input", { + className: "m02-dfver__input", + attrs: { type: "text", placeholder: tv("OwnSample"), "aria-label": d.port }, + }); + input.value = d.example === null || d.example === undefined ? "" : String(d.example); + input.addEventListener("change", () => safe(() => putLogicExample(d.box, d.port, input.value))); + const unit = d.unit && d.unit !== "-" ? ` (${d.unit})` : ""; + return [ + el("li", { + className: "m02-dfver__line", + children: [ + createButton({ + label: `${boxOf(d.box)?.name ?? ""} · ${d.port}${unit}`, + variant: "ghost", + size: "sm", + disabled: !on.select, + onClick: () => on.select?.({ box: d.box, port: d.port }), }), - ] - : [], - ); + input, + ], + }), + ]; + }); const group = (title: TextKey, body: HTMLElement[]): HTMLElement[] => body.length ? [ From 8c200dfc77c7e95369f3580b6dcc44307e19baa6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 19:58:50 +0900 Subject: [PATCH 10/14] =?UTF-8?q?feat(m02-df):=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=ED=8C=90=20=EB=8B=A8=EC=9C=84=20=EC=B9=B8=EC=9D=84=20UnitPick?= =?UTF-8?q?=20=EC=9C=BC=EB=A1=9C=20=E2=80=94=20=EB=B3=80=EC=88=98=20=C2=B7?= =?UTF-8?q?=20=EC=83=88=20=EC=9A=94=EC=86=8C=20=C2=B7=20DF=20=EA=B8=B8?= =?UTF-8?q?=EB=A1=9C=20=ED=99=95=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU --- .../DataFlow/M02_DF_InputPanel.css | 6 +++++ .../DataFlow/M02_DF_InputPanel.ts | 25 +++++++++++-------- .../DataFlow/M02_DF_InputPanel_Vars.ts | 25 ++++++++++++------- 3 files changed, 37 insertions(+), 19 deletions(-) diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.css b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.css index 3ac3c5845..18a1b25f5 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.css +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.css @@ -143,3 +143,9 @@ max-height: 24vh; overflow: auto; } + +.m02-dfip__unitfield { + display: flex; + gap: var(--spacing-8); + align-items: center; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts index e268be002..3fb3a69c5 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts @@ -38,6 +38,7 @@ import { type M01Row, } from "./M02_DF_InputPanel_Build"; import { renderLogicInput } from "./M02_DF_InputPanel_Logic"; +import { NO_UNIT, createUnitPick } from "./M02_DF_UnitPick"; import { renderMaterial } from "./M02_DF_InputPanel_Material"; import { renderTablePick } from "./M02_DF_InputPanel_Table"; import { renderVariables } from "./M02_DF_InputPanel_Vars"; @@ -417,7 +418,18 @@ function newForm( ): HTMLElement { const name = createInputField({ type: "text", label: t("NewName"), compact: true }); const spec = createInputField({ type: "text", label: t("NewSpec"), compact: true }); - const unit = createInputField({ type: "text", label: t("NewUnit"), compact: true }); + let unitValue = ""; + const unit = el("div", { className: "m02-dfip__unitfield" }); + const drawUnit = (): void => + unit.replaceChildren( + el("span", { className: "m02-dfip__muted", text: t("NewUnit") }), + createUnitPick({ + value: unitValue || NO_UNIT, + ariaLabel: t("NewUnit"), + onPick: (u) => ((unitValue = u === NO_UNIT ? "" : u), drawUnit()), + }), + ); + drawUnit(); const value = createInputField({ type: "text", label: t("NewValue"), compact: true }); const go = createButton({ label: t("Make"), @@ -432,7 +444,7 @@ function newForm( group, name: name.input.value.trim(), spec: spec.input.value.trim(), - unit: unit.input.value.trim(), + unit: unitValue, value: n, }).then( (made) => { @@ -455,14 +467,7 @@ function newForm( }); const d = el("details", { className: "m02-dfip__new", - children: [ - el("summary", { text: t("NewHead") }), - name.root, - spec.root, - unit.root, - value.root, - go, - ], + children: [el("summary", { text: t("NewHead") }), name.root, spec.root, unit, value.root, go], }); return d; } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Vars.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Vars.ts index 3c0d9cd0f..555ad4a9d 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Vars.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Vars.ts @@ -7,6 +7,7 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements"; import type { CfLogic, CfVariable } from "./M02_DF_Model"; +import { NO_UNIT, createUnitPick } from "./M02_DF_UnitPick"; import { checkVariable, putVariable, removeVariable } from "./M02_DF_InputPanel_Build"; const TEXT = { @@ -84,13 +85,19 @@ export function renderVariables( compact: true, onChange: () => paint(), }); - const unit = createInputField({ - type: "text", - label: t("Unit"), - value: cur?.unit ?? "", - placeholder: "예: ㎡", - compact: true, - }); + // 단위 = 고르기만(표준 단위 목록) — 손글 안 받음 + let unitValue = cur?.unit ?? ""; + const unit = el("div", { className: "m02-dfip__unitfield" }); + const drawUnit = (): void => + unit.replaceChildren( + el("span", { className: "m02-dfip__muted", text: t("Unit") }), + createUnitPick({ + value: unitValue || NO_UNIT, + ariaLabel: t("Unit"), + onPick: (u) => ((unitValue = u === NO_UNIT ? "" : u), drawUnit()), + }), + ); + drawUnit(); const lo = createInputField({ type: "text", label: t("Min"), @@ -129,7 +136,7 @@ export function renderVariables( const v: CfVariable = { name: name.input.value.trim(), type: ty, - unit: unit.input.value.trim(), + unit: unitValue, example: ex === "" ? null : ty === "number" ? (numOf(ex) ?? ex) : ex, ...(ty === "number" && numOf(lo.input.value) !== null && numOf(hi.input.value) !== null ? { range: [Number(lo.input.value), Number(hi.input.value)] as [number, number] } @@ -164,7 +171,7 @@ export function renderVariables( children: [ name.root, type.root, - unit.root, + unit, extra, example.root, el("div", { className: "m02-dfip__chips", children: cancel ? [ok, cancel] : [ok] }), From ce65d1df3b0838bb874d6287c9ad8e3cbec98ff2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 20:00:56 +0900 Subject: [PATCH 11/14] =?UTF-8?q?feat(m02-df):=20=ED=9D=90=EB=A6=84?= =?UTF-8?q?=EB=8F=84=20=EC=9E=87=EA=B8=B0=20=E2=80=94=20=EA=B3=A0=EB=A6=84?= =?UTF-8?q?=20=C2=B7=20=EC=83=81=EC=84=B8=20=EB=82=B4=EC=9A=A9=20=ED=8C=90?= =?UTF-8?q?=20=C2=B7=20=ED=9D=90=EB=A6=84=20=EA=B8=B8=20=C2=B7=20=EB=81=8A?= =?UTF-8?q?=EA=B8=B4=20=EA=B3=B3=20=C2=B7=20=EB=8B=A4=EB=A5=B8=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=C2=B7=20=ED=91=9C=EC=A4=80=20=ED=91=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 고름 = 상자 · 알약{box,port,dir} · 선 — 다이어그램 · 검증 칸 줄 · 알약 판 상자 이름 누름 모두 같은 고름으로 - 상세 내용 본문 = 입력 · 함수 · 출력 판 · 알약 = renderPillPanel · 선 = renderLinkPanel · 경고 = 끊긴 곳 가운데 고른 것 몫 - 알약 · 선 고름 = Model traceBack/traceForward 로 Diagram opts.trace · Eval breaks 로 Diagram opts.breaks - 다른 로직 입력이 부르는 로직(저장 DF- · 견본)을 겹쳐 읽어 Eval opts.logics 로(셈 · 검사 · 판 모두) - 「표준 표」 단추 = renderStandards 판(공용 모달) - 입력 값 물음 · 결과 단위 후보는 Entry_Detail 로 옮김(700 줄 아래) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M02_MasterTemplete/DataFlow/M02_DF_Entry.ts | 243 +++++++++--------- .../DataFlow/M02_DF_Entry_Detail.ts | 170 +++++++----- 2 files changed, 227 insertions(+), 186 deletions(-) diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts b/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts index 5f35149da..3bec34b5a 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts @@ -18,17 +18,21 @@ import { showConfirmDialog, showToast, } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money"; -import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api"; -import type { CfListItem, CfQuery } from "./M02_DF_Api"; -import type { CfEvalResult, CfValues } from "./M02_DF_Eval"; -import type { CfKind, CfLogic, CfPort } from "./M02_DF_Model"; +import type { CfListItem } from "./M02_DF_Api"; +import type { CfBreak, CfEvalOptions, CfEvalResult, CfValues } from "./M02_DF_Eval"; +import type { CfKind, CfLogic, CfPort, CfStep } from "./M02_DF_Model"; +import type { CfSel } from "./M02_DF_Verify"; +import type { DfPillSel } from "./M02_DF_Diagram_Parts"; import { detailFrame, drawHead, drawWarnings, - lineBody, - pillBody, + fetchValues, + loadLogics, + queriesOf, + unitChoices, type DfPick, } from "./M02_DF_Entry_Detail"; import "./M02_DF_Entry.css"; @@ -45,8 +49,10 @@ const loadMods = () => import("./M02_DF_Summary"), import("./M02_DF_Sample"), import("./M02_DF_InputPanel"), - import("./M02_DF_Rule"), - ]).then(([model, ev, api, diagram, func, output, summary, sample, input, rule]) => ({ + import("./M02_DF_PillPanel"), + import("./M02_DF_LinkPanel"), + import("./M02_DF_Standards"), + ]).then(([model, ev, api, diagram, func, output, summary, sample, input, pill, line, std]) => ({ model, ev, api, @@ -56,7 +62,9 @@ const loadMods = () => summary, sample, input, - rule, + pill, + line, + std, })); type Mods = Awaited>; @@ -81,6 +89,8 @@ const TEXT = { Diagram: ["다이어그램", "Diagram"], Summary: ["원가", "Cost"], Color: ["상자 색", "Box colour"], + Standards: ["표준 표", "Standards"], + Logics: ["다른 로직을 못 읽음 — {v}", "Couldn't read other logics — {v}"], NoName: ["로직 이름을 넣어 주세요", "Enter a logic name"], SaveAnyway: [ "고칠 곳이 있음 — {v} · 그래도 저장할까요?", @@ -113,63 +123,6 @@ const te = (key: TextKey, vars: Record = {}): string => const muted = (text: string): HTMLElement => el("p", { className: "m02-df__muted", text }); -/** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */ -async function unitChoices(): Promise { - const count = new Map(); - try { - for (const x of await fetchLogics()) { - if (x.결과단위.startsWith("원/")) count.set(x.결과단위, (count.get(x.결과단위) ?? 0) + 1); - } - } catch { - /* 못 받으면 기본 하나 */ - } - const units = [...count.entries()] - .filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u)) - .sort((a, b) => b[1] - a[1]) - .map(([u]) => u); - return units.length ? units : ["원/㎡"]; -} - -/** 입력 값 물음 — 그룹 입력 상자의 요소(알약 = 요소 이름) · 표(알약 「표」) · 사용자 요소 · 변수는 Eval 이 스스로 봄 */ -function queriesOf(lg: CfLogic): { q: CfQuery; box: string; port: string }[] { - return lg.boxes.flatMap((b) => { - const inp = b.input; - if (b.kind !== "input" || !inp || inp.mode !== "group" || !inp.group) return []; - const group = inp.group; - const els = inp.elements - .filter((e) => e.ref && !e.ref.startsWith("UE-")) - .map((e) => { - // 기계 요소는 「키#값 칸」 — 서버에는 키와 칸(field)을 따로 넘김 - const [ref, field] = group === "기계" ? e.ref.split("#") : [e.ref, undefined]; - const q: CfQuery = { id: `${b.id}\u0001${e.name}`, group, ref, unit: e.unit }; - if (field) q.field = field; - return { q, box: b.id, port: e.name }; - }); - const table = inp.table - ? [{ q: { id: `${b.id}\u0001표`, group, table: inp.table }, box: b.id, port: "표" }] - : []; - return [...els, ...table]; - }); -} - -/** 입력 값 받기 — 받은 것만 묶고 못 받은 것은 쉬운 말 */ -async function fetchValues(M: Mods, lg: CfLogic): Promise<{ values: CfValues; errors: string[] }> { - const asks = queriesOf(lg); - if (!asks.length) return { values: {}, errors: [] }; - const answers = await M.api.cfValues( - asks.map((a) => a.q), - lg.userElements as never, - ); - const values: CfValues = {}; - const errors: string[] = []; - for (const a of asks) { - const got = answers.find((x) => x.id === a.q.id); - if (!got?.ok) errors.push(`${a.port}${got?.reason ? ` — ${got.reason}` : ""}`); - else (values[a.box] ??= {})[a.port] = got.rows ?? got.value ?? null; - } - return { values, errors }; -} - /** M01 단가산출 막대의 다른 시험판 단추 옆에 놓는 단추 — 누르면 시험판 페이지 */ export function createDataFlowButton(): HTMLButtonElement { return createButton({ label: te("Open"), variant: "filled", size: "sm", onClick: openDataFlow }); @@ -330,6 +283,10 @@ function openDataFlow(): void { const pickedBox = (): string | null => (pick?.kind === "box" ? pick.id : null); let values: CfValues = {}; let valueSig = ""; + let logics: NonNullable = {}; + let logicSig = ""; + let logicNotes: string[] = []; + let broken: CfBreak[] = []; let notes: string[] = []; let result: CfEvalResult; let busy = false; @@ -355,15 +312,15 @@ function openDataFlow(): void { const outHost = el("div"); const inHost = el("div"); const summaryHost = el("div"); - const lineHost = el("div"); + const linkHost = el("div"); const pillHost = el("div"); const detail = detailFrame(); detail.settings.append(colorRow); - detail.body.append(inHost, funcHost, outHost, lineHost, pillHost); + detail.body.append(inHost, funcHost, outHost, linkHost, pillHost); const evalNow = (): CfEvalResult => { try { - return M.ev.evalLogic(lg, values); + return M.ev.evalLogic(lg, values, { logics }); } catch (e) { const cost = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 }; return { ports: {}, outputs: {}, cost, errors: [M.api.cfPlain(e)] }; @@ -387,7 +344,7 @@ function openDataFlow(): void { const sig = JSON.stringify(queriesOf(lg).map((a) => a.q)); if (sig === valueSig) return; valueSig = sig; - void fetchValues(M, lg).then( + void fetchValues(M.api.cfValues, lg).then( (got) => { if (sig !== valueSig) return; values = got.values; @@ -401,6 +358,54 @@ function openDataFlow(): void { ); }; + /** 다른 로직 입력이 부르는 로직들 다시 읽기 — 부르는 열쇠가 바뀐 때만 */ + const refreshLogics = (): void => { + const sig = JSON.stringify(lg.boxes.map((b) => b.input?.logic?.key ?? "")); + if (sig === logicSig) return; + logicSig = sig; + const get = async (key: string): Promise => + key.startsWith("DF-") ? M.api.cfGet(key) : M.sample.sampleLoad(key); + void loadLogics(lg, get).then((got) => { + if (sig !== logicSig) return; + logics = got.logics; + logicNotes = got.errors.length ? [te("Logics", { v: got.errors.join(" · ") })] : []; + redraw(); + }); + }; + + /** 고름 — 검증 칸 줄 · 알약 판의 상자 이름 누름 */ + const choose = (sel: CfSel): void => { + const box = lg.boxes.find((b) => b.id === sel.box); + pick = sel.link + ? { kind: "link", id: sel.link } + : box && sel.port + ? { kind: "pill", box: box.id, port: sel.port, dir: M.model.portDir(box, sel.port) } + : box + ? { kind: "box", id: box.id } + : null; + redraw(); + }; + + /** 흐름 길 — 알약 = 그 알약에서 앞뒤 · 선 = 보낸 알약 앞 · 받는 알약 뒤 */ + const traceNow = (): { back: CfStep[]; forward: CfStep[] } | null => { + try { + if (pick?.kind === "pill") { + const p = { box: pick.box, name: pick.port }; + return { back: M.model.traceBack(lg, p), forward: M.model.traceForward(lg, p) }; + } + const id = pick?.kind === "link" ? pick.id : ""; + const link = lg.links.find((l) => l.id === id); + if (link) + return { + back: M.model.traceBack(lg, link.from), + forward: M.model.traceForward(lg, link.to), + }; + } catch { + /* 따라갈 수 없으면 칠하지 않음 */ + } + return null; + }; + const name = createInputField({ label: te("Name"), placeholder: te("NamePlaceholder"), @@ -417,18 +422,16 @@ function openDataFlow(): void { }); const diagramOn = { - select: (id: string | null): void => { - pick = !id - ? null - : lg.links.some((l) => l.id === id) - ? { kind: "link", id } - : { kind: "box", id }; - drawPanel(); - }, - /** 알약 고름 — 다이어그램(sub8)이 알려 주면 상세 내용이 알약 판으로 */ - pill: (p: { box: string; name: string; dir: "in" | "out" } | null): void => { - pick = p ? { kind: "pill", ...p } : null; - drawPanel(); + select: (id: string | null, pill?: DfPillSel): void => { + pick = pill + ? { kind: "pill", ...pill } + : !id + ? null + : lg.links.some((l) => l.id === id) + ? { kind: "link", id } + : { kind: "box", id }; + // 흐름 길 · 판을 새로 — 다이어그램이 제 그림을 그린 뒤 + setTimeout(redraw); }, move: (id: string, x: number, y: number): void => edit(M.model.moveBox(lg, id, x, y)), link: (from: CfPort, to: CfPort): string | null => { @@ -455,35 +458,20 @@ function openDataFlow(): void { const box = lg.boxes.find((b) => b.id === pickedBox()); drawHead(detail, lg, pick); colorRow.hidden = !box; - lineHost.hidden = pick?.kind !== "link"; + linkHost.hidden = pick?.kind !== "link"; pillHost.hidden = pick?.kind !== "pill"; - if (pick?.kind === "link") { - const linkId = pick.id; - const rule = lg.links.find((l) => l.id === linkId)?.rule; - let text: string | null = null; - try { - text = rule ? M.rule.ruleLabel(rule) : null; - } catch { - text = rule ? rule.kind : null; - } - lineBody(lineHost, lg, pick.id, text); - } - if (pick?.kind === "pill") { - const port = { box: pick.box, name: pick.name }; - pillBody( + if (pick?.kind === "link") + M.line.renderLinkPanel(linkHost, lg, pick.id, { change: edit }, { values, logics }); + if (pick?.kind === "pill") + M.pill.renderPillPanel( pillHost, lg, - () => M.model.traceBack(lg, port), - () => M.model.traceForward(lg, port), + pick.box, + pick.port, + { pick: (id) => choose({ box: id }) }, + { values, logics }, ); - } - let checks: string[] = []; - try { - checks = M.ev.checkLogic(lg, values); - } catch { - checks = []; - } - drawWarnings(detail, lg, pick, [...checks, ...(result?.errors ?? [])]); + drawWarnings(detail, pick, broken); if (box && /^#[0-9a-f]{6}$/i.test(box.color)) colorBox.value = box.color; funcHost.hidden = box?.kind !== "function"; outHost.hidden = box?.kind !== "output"; @@ -497,23 +485,34 @@ function openDataFlow(): void { const redraw = (): void => { result = evalNow(); refreshValues(); + refreshLogics(); + const linkId = pick?.kind === "link" ? pick.id : ""; + if (linkId && !lg.links.some((l) => l.id === linkId)) pick = null; + try { + broken = M.ev.breaks(lg, values, { logics }); + } catch { + broken = result.breaks ?? []; + } unsaved.hidden = lg === savedLg; undo.disabled = !stack.length; deleteButton.hidden = !lg.key; - const selected = - pick?.kind === "box" || pick?.kind === "link" ? pick.id : (pick?.box ?? null); - M.diagram.renderDiagram(diagramHost, lg, diagramOn, { values: result.ports, selected }); + M.diagram.renderDiagram(diagramHost, lg, diagramOn, { + values: result.ports, + selected: pick?.kind === "box" || pick?.kind === "link" ? pick.id : null, + selectedPill: + pick?.kind === "pill" ? { box: pick.box, port: pick.port, dir: pick.dir } : null, + trace: traceNow(), + breaks: broken, + }); drawPanel(); - // 「고칠 것」 검사가 받은 입력 값으로 하도록 값도 넘김 — Summary 가 opts.values 를 받으면 쓰임 - // ponytail: 지금 Summary 는 checkLogic(lg) 을 값 없이 불러 「값이 아직 없음」 이 남음(sub5 고침 뒤 이 꼴 그대로 둠) - (M.summary.renderSummary as (...args: unknown[]) => void)( + M.summary.renderSummary( summaryHost, lg, result, - { change: edit }, + { change: edit, select: choose }, { values }, ); - note.textContent = [...notes, ...result.errors].slice(0, 4).join("\n"); + note.textContent = [...notes, ...logicNotes, ...result.errors].slice(0, 4).join("\n"); }; const save = async (): Promise => { @@ -524,7 +523,7 @@ function openDataFlow(): void { } let problems: string[] = []; try { - problems = M.ev.checkLogic(lg, values); + problems = M.ev.checkLogic(lg, values, { logics }); } catch { problems = []; } @@ -588,6 +587,18 @@ function openDataFlow(): void { el("div", { className: "m02-df__actions", children: [ + createButton({ + label: te("Standards"), + variant: "ghost", + size: "sm", + onClick: () => + openModal({ + title: te("Standards"), + closeLabel: te("Close"), + mount: (host: HTMLElement) => + M.std.renderStandards(host, { change: () => redraw() }), + }), + }), undo, createButton({ label: te("Save"), size: "sm", onClick: () => void save() }), deleteButton, diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Entry_Detail.ts b/M02_MasterTemplete/DataFlow/M02_DF_Entry_Detail.ts index be13d2dd1..fc27df74b 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Entry_Detail.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Entry_Detail.ts @@ -2,12 +2,15 @@ * M02_DF_Entry_Detail.ts * 단가산출 흐름도 — 「상세 내용」 칸 틀(정함 9): 머리(왼쪽 「상세 내용」 · 오른쪽 고른 상자 제목 · 알약이면 그 옆 알약 이름) * · 설정 · 본문 · 경고 차례. 상자 · 알약 · 선 무엇을 골라도 바뀜 — 본문은 Entry 가 종류별 판을 끼움. - * 선 · 알약 본문은 규칙 판 · 알약 판(sub2)이 오기 전까지 이 파일이 흐름 줄(보내는 곳 → 받는 곳 · 출처 사슬 · 가는 곳)만 보임. + * 경고 = 끊긴 곳 가운데 고른 것 몫 · 다른 로직 입력이 부르는 로직 읽기(겹쳐 부름) · 입력 값 물음 · 결과 단위 후보. * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { el } from "@ui/ui_template_elements"; -import type { CfLogic, CfPort, CfStep } from "./M02_DF_Model"; +import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api"; +import type { CfAnswer, CfQuery } from "./M02_DF_Api"; +import type { CfBreak, CfValues } from "./M02_DF_Eval"; +import type { CfLogic } from "./M02_DF_Model"; const TEXT = { Detail: ["상세 내용", "Details"], @@ -20,14 +23,6 @@ const TEXT = { "Choose a box, pill or line in the diagram", ], Line: ["선", "Line"], - From: ["보내는 곳", "From"], - To: ["받는 곳", "To"], - Rule: ["선 규칙", "Line rule"], - RuleNone: ["없음 — 값을 그대로 보냄", "None — sends the value as is"], - Back: ["온 길(출처)", "Came from"], - Forward: ["가는 길", "Goes to"], - NoTrace: ["아직 따라갈 수 없음", "Can't trace yet"], - Nowhere: ["이어진 곳 없음", "Not connected"], In: ["받는 알약", "Incoming pill"], Out: ["내보내는 알약", "Outgoing pill"], } as const; @@ -39,7 +34,7 @@ const tt = (key: keyof typeof TEXT): string => export type DfPick = | { kind: "box"; id: string } | { kind: "link"; id: string } - | { kind: "pill"; box: string; name: string; dir: "in" | "out" } + | { kind: "pill"; box: string; port: string; dir: "in" | "out" } | null; export interface DetailParts { @@ -91,7 +86,6 @@ export function detailFrame(): DetailParts { } const boxName = (lg: CfLogic, id: string): string => lg.boxes.find((b) => b.id === id)?.name ?? id; -const portText = (lg: CfLogic, p: CfPort): string => `${boxName(lg, p.box)} · ${p.name}`; /** 머리 · 빈 자리 — 고른 것에 맞춤 · 설정 · 경고 줄은 Entry 가 채움 */ export function drawHead(d: DetailParts, lg: CfLogic, pick: DfPick): void { @@ -107,77 +101,113 @@ export function drawHead(d: DetailParts, lg: CfLogic, pick: DfPick): void { ? `${tt("Line")} · ${boxName(lg, link.from.box)} → ${boxName(lg, link.to.box)}` : ""; d.pill.textContent = - pick?.kind === "pill" ? `${tt(pick.dir === "in" ? "In" : "Out")} · ${pick.name}` : ""; + pick?.kind === "pill" ? `${tt(pick.dir === "in" ? "In" : "Out")} · ${pick.port}` : ""; d.pill.hidden = pick?.kind !== "pill"; } -/** 경고 — 화면 계산 · 검사 글 가운데 고른 것(상자 이름 · 알약 이름)이 든 줄만 */ -export function drawWarnings(d: DetailParts, lg: CfLogic, pick: DfPick, all: string[]): void { - const names: string[] = []; - if (pick?.kind === "box") names.push(`「${boxName(lg, pick.id)}」`); - if (pick?.kind === "pill") names.push(`「${boxName(lg, pick.box)}」`); - if (pick?.kind === "link") { - const l = lg.links.find((x) => x.id === pick.id); - if (l) names.push(`「${boxName(lg, l.from.box)}」`, `「${boxName(lg, l.to.box)}」`); - } - const mine = [...new Set(all)].filter( - (w) => names.some((n) => w.includes(n)) && (pick?.kind !== "pill" || w.includes(pick.name)), +/** 경고 — 끊긴 곳(Eval breaks) 가운데 고른 것 몫만 · 상자 = 그 상자 · 알약 = 그 알약 · 선 = 그 선 */ +export function drawWarnings(d: DetailParts, pick: DfPick, all: CfBreak[]): void { + const mine = all.filter((k) => + pick?.kind === "box" + ? k.box === pick.id + : pick?.kind === "pill" + ? k.box === pick.box && k.port === pick.port && !k.link + : pick?.kind === "link" + ? k.link === pick.id + : false, ); d.warn.replaceChildren( ...(mine.length - ? mine.map((w) => el("li", { text: w })) + ? [...new Set(mine.map((k) => k.text))].map((w) => el("li", { text: w })) : [el("li", { className: "m02-df__muted", text: tt("WarnNone") })]), ); } -/** 선 본문 — 보내는 곳 → 받는 곳 · 선 규칙 글(규칙 판이 오면 그쪽이 그림) */ -export function lineBody( - host: HTMLElement, +/** 다른 로직 입력이 부르는 로직들 — 그 로직이 또 부르는 것까지(겹쳐 부름 · 한 번씩) · 못 읽은 것은 빼고 까닭 */ +export async function loadLogics( lg: CfLogic, - id: string, - ruleText: string | null, -): void { - const l = lg.links.find((x) => x.id === id); - if (!l) return void host.replaceChildren(); - host.replaceChildren( - el("dl", { - className: "m02-df__detail-dl", - children: [ - el("dt", { text: tt("From") }), - el("dd", { text: portText(lg, l.from) }), - el("dt", { text: tt("To") }), - el("dd", { text: portText(lg, l.to) }), - el("dt", { text: tt("Rule") }), - el("dd", { text: ruleText ?? tt("RuleNone") }), - ], - }), - ); + get: (key: string) => Promise, +): Promise<{ logics: Record; errors: string[] }> { + const logics: Record = {}; + const errors: string[] = []; + const keysOf = (x: CfLogic): string[] => + x.boxes.flatMap((b) => + b.input?.mode === "logic" && b.input.logic?.key ? [b.input.logic.key] : [], + ); + const seen = new Set(lg.key ? [lg.key] : []); + let todo = keysOf(lg); + while (todo.length) { + const key = todo.shift()!; + if (seen.has(key)) continue; + seen.add(key); + try { + const got = await get(key); + if (!got) throw new Error(key); + logics[key] = got; + todo = [...todo, ...keysOf(got)]; + } catch (e) { + errors.push(`${key} — ${e instanceof Error ? e.message : String(e)}`); + } + } + return { logics, errors }; } -/** 알약 본문 — 온 길(출처 사슬) · 가는 길 · 흐름 따라가기(Model traceBack · traceForward)가 아직이면 한 줄 */ -export function pillBody( - host: HTMLElement, - lg: CfLogic, - back: () => CfStep[], - forward: () => CfStep[], -): void { - const list = (steps: () => CfStep[]): HTMLElement => { - let got: CfStep[]; - try { - got = steps(); - } catch { - return el("p", { className: "m02-df__muted", text: tt("NoTrace") }); +/** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */ +export async function unitChoices(): Promise { + const count = new Map(); + try { + for (const x of await fetchLogics()) { + if (x.결과단위.startsWith("원/")) count.set(x.결과단위, (count.get(x.결과단위) ?? 0) + 1); } - if (!got.length) return el("p", { className: "m02-df__muted", text: tt("Nowhere") }); - return el("ol", { - className: "m02-df__detail-trace", - children: got.map((s) => el("li", { text: portText(lg, { box: s.box, name: s.port }) })), - }); - }; - host.replaceChildren( - el("h5", { text: tt("Back") }), - list(back), - el("h5", { text: tt("Forward") }), - list(forward), + } catch { + /* 못 받으면 기본 하나 */ + } + const units = [...count.entries()] + .filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u)) + .sort((a, b) => b[1] - a[1]) + .map(([u]) => u); + return units.length ? units : ["원/㎡"]; +} + +/** 입력 값 물음 — 그룹 입력 상자의 요소(알약 = 요소 이름) · 표(알약 「표」) · 사용자 요소 · 변수는 Eval 이 스스로 봄 */ +export function queriesOf(lg: CfLogic): { q: CfQuery; box: string; port: string }[] { + return lg.boxes.flatMap((b) => { + const inp = b.input; + if (b.kind !== "input" || !inp || inp.mode !== "group" || !inp.group) return []; + const group = inp.group; + const els = inp.elements + .filter((e) => e.ref && !e.ref.startsWith("UE-")) + .map((e) => { + // 기계 요소는 「키#값 칸」 — 서버에는 키와 칸(field)을 따로 넘김 + const [ref, field] = group === "기계" ? e.ref.split("#") : [e.ref, undefined]; + const q: CfQuery = { id: `${b.id}\u0001${e.name}`, group, ref, unit: e.unit }; + if (field) q.field = field; + return { q, box: b.id, port: e.name }; + }); + const table = inp.table + ? [{ q: { id: `${b.id}\u0001표`, group, table: inp.table }, box: b.id, port: "표" }] + : []; + return [...els, ...table]; + }); +} + +/** 입력 값 받기 — 받은 것만 묶고 못 받은 것은 쉬운 말 */ +export async function fetchValues( + ask: (qs: CfQuery[], els: never) => Promise, + lg: CfLogic, +): Promise<{ values: CfValues; errors: string[] }> { + const asks = queriesOf(lg); + if (!asks.length) return { values: {}, errors: [] }; + const answers = await ask( + asks.map((a) => a.q), + lg.userElements as never, ); + const values: CfValues = {}; + const errors: string[] = []; + for (const a of asks) { + const got = answers.find((x) => x.id === a.q.id); + if (!got?.ok) errors.push(`${a.port}${got?.reason ? ` — ${got.reason}` : ""}`); + else (values[a.box] ??= {})[a.port] = got.rows ?? got.value ?? null; + } + return { values, errors }; } From 2d459f7b13cb95437ab5da7b916fdf45bf1840fa Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 20:01:08 +0900 Subject: [PATCH 12/14] =?UTF-8?q?feat(m02-df):=20=ED=91=9C=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=EC=B9=B8=20=EC=88=AB=EC=9E=90=20=ED=95=98=EB=82=98?= =?UTF-8?q?(=EC=95=84=EB=9E=98=20=3D=20=EC=9C=84)=EB=8A=94=20=EA=B0=99?= =?UTF-8?q?=EC=9D=8C=EC=9C=BC=EB=A1=9C=20=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 표에서 찾기 범위 칸: 아래 = 위면 값 == 그 수 · 다르면 아래 < 값 <= 위(빈 끝 열림) — 견치돌 높이 4 같은 줄 - 시험 24개 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk --- M02_MasterTemplete/DataFlow/M02_DF_Rule.ts | 6 ++++-- resources/tester/test_m02_df_model.py | 11 +++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Rule.ts b/M02_MasterTemplete/DataFlow/M02_DF_Rule.ts index 99b13b959..adf25c2fa 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Rule.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Rule.ts @@ -3,7 +3,8 @@ * 선 규칙 · 끝수 → 엑셀 수식 — 우리 스프레드시트 엔진 한 벌로 셈 · 선 위 규칙 글(쉬운 말) · DOM 없음 * * 한 번 셈 = 시트 하나: A1 부터 보낸 값(표면 머리 줄 포함 덩어리) · 그 오른쪽에 조건 값 · 맨 끝 칸에 규칙 수식 - * lookup = 줄마다 「맞음」 칸(같음 · 범위는 「이름·아래」 < 값 ≤ 「이름·위」 · 빈 끝은 열림) → 맞은 줄 수 · INDEX/MATCH + * lookup = 줄마다 「맞음」 칸(같음 · 범위는 「이름·아래」 < 값 ≤ 「이름·위」 · 빈 끝은 열림 · 아래 = 위면 값 == 그 수) + * → 맞은 줄 수 · INDEX/MATCH * gate = IF(조건, 보낸 값, NA()) · scale = × ÷ (1 ± %) · round = ROUND · ROUNDUP · ROUNDDOWN * ========================================================================== */ @@ -177,7 +178,8 @@ function lookup( else if (low && high) tests.push( (r) => - `AND(OR(ISBLANK(${low}${r}),${low}${r}<${ref}),OR(ISBLANK(${high}${r}),${ref}<=${high}${r}))`, + `IF(AND(NOT(ISBLANK(${low}${r})),${low}${r}=${high}${r}),${ref}=${low}${r},` + + `AND(OR(ISBLANK(${low}${r}),${low}${r}<${ref}),OR(ISBLANK(${high}${r}),${ref}<=${high}${r})))`, ); else bad = true; }); diff --git a/resources/tester/test_m02_df_model.py b/resources/tester/test_m02_df_model.py index 56bdf7c3d..449a25a04 100644 --- a/resources/tester/test_m02_df_model.py +++ b/resources/tester/test_m02_df_model.py @@ -609,3 +609,14 @@ def test_다_다른_로직_받는_알약_예시값_차례() -> None: assert (a, b, c) == (70000, 14000, 21000) assert designs == [["변수", "길이", 7], ["다른 로직", "계수", 10]] assert plain == [7, None] + + +def test_표_범위칸_숫자하나는_같음() -> None: + got = _run( + RULE + + "const T = [['높이·아래', '높이·위', '석공'], [4, 4, 0.5], [4, 6, 0.6], [null, 3, 0.4]];\n" + "const rule = { kind: 'lookup', conds: [{ col: '높이', from: { box: 'b1', name: '높이' } }], col: '석공' };\n" + "out([4, 5, 2, 3.5, 6].map(h => { const r = R.applyRule(rule, T, { 높이: h }); return [r.value, r.why]; }));" + ) + # 4 = 「4」 줄만(같음) · 4 는 「4~6」(초과) 에 안 듦 · 빈 아래 끝은 열림 + assert got == [[0.5, None], [0.6, None], [0.4, None], [None, "표에 없는 조건"], [0.6, None]] From 1fc2885f76c4dfa746321bf52516d295be796bed Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 20:01:23 +0900 Subject: [PATCH 13/14] =?UTF-8?q?refactor(m02-df):=20=EC=9E=AC=EB=A3=8C=20?= =?UTF-8?q?=EC=B0=BE=EA=B8=B0=20=C2=B7=20=EB=8B=A8=EA=B0=80=20=C2=B7=20?= =?UTF-8?q?=EA=B8=88=EC=95=A1=20=ED=91=9C=EC=A4=80=EC=9D=84=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EA=B8=B8(cfMaterials=20=C2=B7=20cfStandards)?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=94=EA=BE=B8=EA=B3=A0=20=EB=8C=80=EC=8B=A0=20?= =?UTF-8?q?=EA=B8=B8=EC=9D=84=20=EC=A7=80=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr --- .../DataFlow/M02_DF_Material.ts | 96 +++---------------- M02_MasterTemplete/DataFlow/M02_DF_Round.ts | 31 ++---- 2 files changed, 22 insertions(+), 105 deletions(-) diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Material.ts b/M02_MasterTemplete/DataFlow/M02_DF_Material.ts index 01e1083b2..27204e11a 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Material.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Material.ts @@ -1,20 +1,13 @@ /* ============================================================================= * M02_DF_Material.ts - * 검증 칸이 쓰는 재료 찾기 · 이름 · 단가 — 서버 `GET /api/m02/df/materials?q&sub&detail&page&size` - * → {total, page, size, items}(아직 없으면 M01 재료 찾기로 대신) · 단가는 `cfValues`(재료 ref = 키) + * 검증 칸이 쓰는 재료 찾기 · 이름 · 단가 — 서버 `GET /api/m02/df/materials`(Api `cfMaterials` · + * 띄어쓰기로 나눈 말을 모두 포함 · 쪽 넘기기) · 단가 = 그 줄의 단가(값을 받는 길과 같은 셈) * 모양은 docs/raw/M01_쉬운단가/흐름도_정함.md 5. 이 폴더째 지워도 됨. * ========================================================================== */ -import { API_BASE_URL } from "@config/config_frontend"; -import { searchElements } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api"; -import { cfValues } from "./M02_DF_Api"; +import { cfMaterials, type CfMaterial } from "./M02_DF_Api"; -export interface CfMat { - ref: string; - name: string; - spec: string; - unit: string; -} +export type CfMat = CfMaterial; export interface CfMatPage { total: number; @@ -23,99 +16,34 @@ export interface CfMatPage { items: CfMat[]; } -const text = (v: unknown): string => - typeof v === "string" || typeof v === "number" ? String(v) : ""; - -const norm = (it: Record): CfMat => ({ - ref: text(it.ref ?? it.키), - name: text(it.이름 ?? it.name), - spec: text(it.규격 ?? it.spec), - unit: text(it.단위 ?? it.unit), -}); - /** 재료 찾기 — q = 찾는 말 · sub = 구분 · detail = 상세구분 · page 는 1 부터 */ -export async function findMaterials(cond: { +export function findMaterials(cond: { q?: string; sub?: string; detail?: string; page?: number; size?: number; }): Promise { - const page = cond.page ?? 1; - const size = cond.size ?? 10; - try { - const qs = new URLSearchParams({ - q: cond.q ?? "", - sub: cond.sub ?? "", - detail: cond.detail ?? "", - page: String(page), - size: String(size), - }); - const r = await fetch(`${API_BASE_URL}/m02/df/materials?${qs.toString()}`, { - credentials: "include", - }); - if (r.ok) { - const d = (await r.json()) as { - total: number; - page?: number; - size?: number; - items: Record[]; - }; - return { - total: d.total, - page: d.page ?? page, - size: d.size ?? size, - items: d.items.map(norm), - }; - } - } catch { - /* 서버 길이 아직 없으면 M01 재료 찾기로 */ - } - const got = await searchElements("재료", cond.q ?? ""); - const all = got.items.filter((it) => { - const x = it as unknown as Record; - return (!cond.sub || x.구분 === cond.sub) && (!cond.detail || x.상세구분 === cond.detail); - }); - const from = (page - 1) * size; - return { - total: all.length, - page, - size, - items: all.slice(from, from + size).map((it) => norm(it as unknown as Record)), - }; + return cfMaterials({ ...cond, page: cond.page ?? 1, size: cond.size ?? 10 }); } -const names = new Map(); +const byKey = new Map(); -/** 재료 키 → 이름 · 규격 (한 번 물은 것은 다시 안 물음) */ +/** 재료 키 → 그 줄(이름 · 규격 · 단가) — 한 번 물은 것은 다시 안 물음 */ export async function materialOf(key: string): Promise { if (!key) return null; - if (names.has(key)) return names.get(key) ?? null; + if (byKey.has(key)) return byKey.get(key) ?? null; let hit: CfMat | null = null; try { - const got = await searchElements("재료", key); - const it = got.items.find((x) => x.ref === key); - hit = it ? norm(it as unknown as Record) : null; + hit = (await cfMaterials({ q: key, size: 20 })).items.find((m) => m.ref === key) ?? null; } catch { hit = null; } - names.set(key, hit); + byKey.set(key, hit); return hit; } -const prices = new Map(); - /** 재료 키 → 단가(원) — 못 구하면 null */ export async function priceOf(key: string): Promise { - if (!key) return null; - if (prices.has(key)) return prices.get(key) ?? null; - let v: number | null = null; - try { - const a = (await cfValues([{ id: "p", group: "재료", ref: key }]))[0]; - v = a?.ok && typeof a.value === "number" ? a.value : null; - } catch { - v = null; - } - prices.set(key, v); - return v; + return (await materialOf(key))?.price ?? null; } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Round.ts b/M02_MasterTemplete/DataFlow/M02_DF_Round.ts index b4976a9d7..3d1a54d90 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Round.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Round.ts @@ -2,11 +2,11 @@ * M02_DF_Round.ts * 끝수 기본값 — 금액 단위 표준(산림품셈 1-2-2 「금액의 단위표준」)을 읽어 * 출력(비용) 기본 = 일위대가표 금액란 · 원가 합계 기본 = 일위대가표 계금 (모두 미만 버림) - * 표 읽기 = 서버 `GET /api/m02/df/standards` → {unit, money, saved} · 아직 없으면 `_standard/금액단위표준.json` + * 표 읽기 = 서버 `GET /api/m02/df/standards`(Api `cfStandards`) → {unit, money, saved} * 모양은 docs/raw/M01_쉬운단가/흐름도_정함.md 11 · 12. 이 폴더째 지워도 됨. * ========================================================================== */ -import { API_BASE_URL } from "@config/config_frontend"; +import { cfStandards } from "./M02_DF_Api"; import type { CfRound } from "./M02_DF_Model"; /** 표 한 줄 — 원문 그대로(종목 · 단위 · 지위 · 비고) */ @@ -17,11 +17,6 @@ export interface CfMoneyRow { 비고?: string; } -const FILES = import.meta.glob<{ 줄?: CfMoneyRow[] }>("./_standard/금액단위표준.json", { - eager: true, - import: "default", -}); - /** 표 모양 둘(`{줄: […]}` · 줄 목록)을 줄 목록으로 */ const rowsOf = (x: unknown): CfMoneyRow[] => { const list = Array.isArray(x) ? x : (x as { 줄?: unknown } | null)?.줄; @@ -30,21 +25,15 @@ const rowsOf = (x: unknown): CfMoneyRow[] => { let loaded: Promise | null = null; -/** 금액 단위 표준 줄 — 사용자가 고쳐 저장한 것이 있으면 그것 · 서버가 없으면 처음 값 */ +/** 금액 단위 표준 줄 — 서버가 고친 것(`_data`)이 있으면 그것 · 없으면 처음 값을 줌 */ export function loadMoneyStandard(): Promise { - loaded ??= (async () => { - try { - const r = await fetch(`${API_BASE_URL}/m02/df/standards`, { credentials: "include" }); - if (r.ok) { - const d = (await r.json()) as { money?: unknown; saved?: { money?: unknown } | null }; - const rows = rowsOf(d.saved?.money ?? d.money); - if (rows.length) return rows; - } - } catch { - /* 서버가 없으면 처음 값 */ - } - return rowsOf(Object.values(FILES)[0]); - })(); + loaded ??= cfStandards().then( + (s) => rowsOf(s.money), + () => { + loaded = null; // 못 읽었으면 다음에 다시 — 지금은 정해 둔 기본값으로 + return []; + }, + ); return loaded; } From 6c645585f41f52560a5ab68bc8e7c4878ec03926 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 20:02:55 +0900 Subject: [PATCH 14/14] =?UTF-8?q?fix(m02-df):=20=ED=91=9C=EC=A4=80=20?= =?UTF-8?q?=ED=91=9C=20=ED=8C=90=EC=9D=84=20=EC=84=9C=EB=B2=84=20Api(cfSta?= =?UTF-8?q?ndards=20=C2=B7=20Save=20=C2=B7=20Reset)=EB=A1=9C=20=C2=B7=20?= =?UTF-8?q?=EB=8C=80=EC=8B=A0=20=EA=B8=B8=20=EC=A7=80=EC=9B=80=20=C2=B7=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EB=8B=B5=20=3D=20=EC=B2=98=EC=9D=8C=20?= =?UTF-8?q?=EA=B0=92=20=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 견본 시험의 /calc 대조를 Store.calc 로(옛 LogicBuilder 폴더 지움 뒤) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt --- .../DataFlow/M02_DF_Standards.ts | 31 +++++++++---------- resources/tester/test_m02_df_sample.py | 9 ++---- resources/tester/test_m02_df_standards.py | 12 +++++++ 3 files changed, 30 insertions(+), 22 deletions(-) diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Standards.ts b/M02_MasterTemplete/DataFlow/M02_DF_Standards.ts index a94582b9b..8d4bd646b 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Standards.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Standards.ts @@ -2,7 +2,8 @@ * M02_DF_Standards.ts * 단가산출 흐름도 — 단위 · 금액 표준 표(정함 11 · 12) * 처음 값 = `_standard/단위표준.json` · `금액단위표준.json`(산림품셈 1-2-2 · 원문 흠 그대로 — 사용자가 고침) - * 서버 = `GET · PUT · DELETE /api/m02/df/standards` → {unit, money, saved} · 고친 것은 `_data`(마스터에 안 씀) + * 서버 = `M02_DF_Api` cfStandards · cfStandardsSave · cfStandardsReset(`/api/m02/df/standards`) → {unit, money, saved} · + * 고친 것은 `_data`(마스터에 안 씀) * `renderStandards(host, on)` = 시험판 안 「표준 표」 판(두 표 보기 · 칸 고치기 · 저장 · 처음 값으로) * `standardsList()` = 단위 고르기 · 끝수 제안이 부르는 목록(동기 · 처음엔 번들 값 · 판이 서버 값을 받으면 그것) * 이 폴더째 지워도 됨. @@ -10,7 +11,7 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; -import { API_BASE_URL } from "@config/config_frontend"; +import { cfStandards, cfStandardsReset, cfStandardsSave, type CfStandards } from "./M02_DF_Api"; import unitDefault from "./_standard/단위표준.json"; import moneyDefault from "./_standard/금액단위표준.json"; import "./M02_DF_Standards.css"; @@ -148,21 +149,12 @@ export const standardsNow = (): Standards => clone(current); /* ── 서버 ─────────────────────────────────────────────────────────────── */ -async function call(method: "GET" | "PUT" | "DELETE", body?: unknown): Promise { - const response = await fetch(`${API_BASE_URL}/m02/df/standards`, { - method, - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: body === undefined ? undefined : JSON.stringify(body), - }); - const data = (await response.json().catch(() => ({}))) as Standards & { detail?: unknown }; - if (!response.ok) throw new Error(String(data.detail ?? response.statusText)); - return data; -} +/** 서버 답(`M02_DF_Api` CfStandards) → 이 판의 모양 */ +const asStd = (v: CfStandards): Standards => v as unknown as Standards; /** 서버 표 받기 — 못 받으면 처음 값 */ export async function loadStandards(): Promise { - current = await call("GET"); + current = asStd(await cfStandards()); return clone(current); } @@ -311,7 +303,14 @@ export function renderStandards(host: HTMLElement, on: StandardsOn = {}): void { async function doSave(): Promise { save.disabled = true; try { - take(await call("PUT", { unit: work.unit, money: work.money })); + take( + asStd( + await cfStandardsSave({ unit: work.unit, money: work.money } as unknown as Omit< + CfStandards, + "saved" + >), + ), + ); showToast(tx("Saved"), "success"); } catch { showToast(tx("SaveFailed"), "error"); @@ -322,7 +321,7 @@ export function renderStandards(host: HTMLElement, on: StandardsOn = {}): void { async function doReset(): Promise { if (!(await showConfirmDialog(tx("ResetAsk"), tx("Reset")))) return; try { - take(await call("DELETE")); + take(asStd(await cfStandardsReset())); } catch { take(clone(ORIGINAL)); // 서버가 없으면 화면만 처음 값 } diff --git a/resources/tester/test_m02_df_sample.py b/resources/tester/test_m02_df_sample.py index e74d11578..11bc2f9af 100644 --- a/resources/tester/test_m02_df_sample.py +++ b/resources/tester/test_m02_df_sample.py @@ -337,18 +337,15 @@ def _run(lg: dict, master, examples: dict | None = None) -> dict: def test_값_메쌓기_value_는_calc_와_같음(master) -> None: - from M01_MasterData.LogicBuilder import M01_LB_Calc as lb + from M01_MasterData import M01_MasterData_Store as store - _, files = master - rows = {r["키"]: r for d in files.values() if d.get("그룹") == "로직" for r in d["줄"]} - raw = lambda k: json.loads(json.dumps(rows[k], default=_plain)) # noqa: E731 given = {v["name"]: v["example"] for v in SAMPLES["masonry"]["boxes"][0]["input"]["variables"]} - want = lb.calc(raw("GF000219"), {**given, "초과증가율": 80}, {}) + want = store.calc("GF000219", {**given, "초과증가율": 80}) assert abs(_run(SAMPLES["masonry"], master)["노무비"] - float(want["sums"]["계"])) < 1 given = {v["name"]: v["example"] for v in SAMPLES["value"]["boxes"][0]["input"]["variables"]} given["qt"] = given.pop("q") assert _run(SAMPLES["value"], master)["시간당 작업량"] == float( - lb.calc(raw("GF000133"), given, {})["result"] + store.calc("GF000133", given)["result"] ) diff --git a/resources/tester/test_m02_df_standards.py b/resources/tester/test_m02_df_standards.py index 2aae5fe0d..0c69a23b6 100644 --- a/resources/tester/test_m02_df_standards.py +++ b/resources/tester/test_m02_df_standards.py @@ -143,3 +143,15 @@ def test_standardsList_목록() -> None: (1, 0, "버림"), (0.1, 1, "버림"), ] + + +def test_서버_표준_답이_처음_값과_같음() -> None: + """서버(`M02_DF_Values.standards`) 답 = `_standard` 처음 값 · 그 답으로 만든 목록 = 번들 처음 값 목록""" + from M02_MasterTemplete.DataFlow import M02_DF_Values as dv + + mine = STD.parent / "_data" / "시험-없는-폴더" # 만들지 않음 — 고친 것 없음 → 처음 값 + assert not mine.exists() + got = dv.standards(STD, mine) + assert got == {"unit": UNIT, "money": MONEY, "saved": False} + server = _node(f"out(S.standardsList({json.dumps(got, ensure_ascii=False)}));") + assert server == _node("out(S.standardsList());")