fix(M02-흐름도): 상세 내용 판을 모델 몸통에 이음 - 빈 함수 대신 traceBack·breaks·arrived 를 씀

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
This commit is contained in:
2026-10-01 19:52:01 +09:00
co-authored by Claude Sonnet 5.5
parent f759216242
commit 083f320ac4
3 changed files with 78 additions and 203 deletions
+60 -178
View File
@@ -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, string | number> = {}): string =>
@@ -48,16 +45,22 @@ export const td = (key: keyof typeof TEXT, fill: Record<string, string | number>
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<string, unknown> = {};
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<CfRule, { kind: "gate" }>;
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<string>();
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");
@@ -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"));
+11 -13
View File
@@ -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", {