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:
@@ -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"));
|
||||
|
||||
@@ -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", {
|
||||
|
||||
Reference in New Issue
Block a user