Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
255 lines
9.5 KiB
TypeScript
255 lines
9.5 KiB
TypeScript
/* =============================================================================
|
||
* M02_DF_Detail.ts
|
||
* 상세 내용 판(선 · 알약) 공용 — 값 읽기 · 규칙 미리 보기 · 겹침/안 맞음 · 흐름 줄 · 끊김 까닭 · DOM 없음
|
||
* 모델의 traceBack · traceForward · breaks · applyRule · ruleLabel 이 아직 준비 중이면 같은 뜻의 작은 셈으로 메움
|
||
* 글 [한, 영] 짝은 아래 TEXT
|
||
* ========================================================================== */
|
||
|
||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||
import { breaks, evalLogic, type CfEvalResult, type CfValue, type CfValues } from "./M02_DF_Eval";
|
||
import {
|
||
portsIn,
|
||
portsOut,
|
||
traceBack,
|
||
traceForward,
|
||
type CfBox,
|
||
type CfGateOp,
|
||
type CfLink,
|
||
type CfLogic,
|
||
type CfPort,
|
||
type CfRound,
|
||
type CfRule,
|
||
type CfStep,
|
||
} from "./M02_DF_Model";
|
||
import { applyRule, 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 =>
|
||
Object.entries(fill).reduce(
|
||
(s, [k, v]) => s.replace(`{${k}}`, String(v)),
|
||
TEXT[key][currentLanguageIndex === 1 ? 1 : 0] as string,
|
||
);
|
||
|
||
export const GATE_OPS: CfGateOp[] = ["=", "≠", "≥", "≤", ">", "<"];
|
||
export const SCALE_OPS: ("×" | "÷" | "+%" | "-%")[] = ["×", "÷", "+%", "-%"];
|
||
|
||
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 {
|
||
try {
|
||
return evalLogic(lg, values);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export const valueOf = (ev: CfEvalResult | null, p: CfPort): CfValue | undefined =>
|
||
ev?.ports[p.box]?.[p.name];
|
||
|
||
/** 화면에 보일 값 글 — 표는 줄 수 */
|
||
export function showValue(v: CfValue | undefined): string {
|
||
if (v === undefined || v === null) return td("Empty");
|
||
if (Array.isArray(v)) return td("Table", { n: Math.max(0, v.length - 1) });
|
||
return typeof v === "number" ? String(Number(v.toPrecision(12))) : String(v);
|
||
}
|
||
|
||
export function unitOf(lg: CfLogic, p: CfPort): string {
|
||
const box = boxOf(lg, p.box);
|
||
return (box && portsOut(box).find((x) => x.name === p.name)?.unit) || box?.unit || "-";
|
||
}
|
||
|
||
/** 알약이 나가는(out) 쪽인가 */
|
||
export const isOut = (box: CfBox, name: string): boolean =>
|
||
portsOut(box).some((p) => p.name === name);
|
||
|
||
/* ── 규칙 ─────────────────────────────────────────────────────────────── */
|
||
|
||
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 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 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") — 모델 것, 없으면 작은 셈 */
|
||
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;
|
||
}
|
||
}
|
||
|
||
/** 그 상자(알약)에서 끊긴 곳 — 쉬운 말 */
|
||
export function breakLines(lg: CfLogic, boxId: string, name?: string): string[] {
|
||
try {
|
||
return breaks(lg)
|
||
.filter((b) => b.box === boxId && (!name || !b.port || b.port === name))
|
||
.map((b) => b.text);
|
||
} 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;
|
||
}
|
||
|
||
export const noValue = (): string => td("NoValue");
|