/* ============================================================================= * 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 => 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 = {}; 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") — 모델 것, 없으면 작은 셈 */ 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; } } /** 그 상자(알약)에서 끊긴 곳 — 쉬운 말 */ 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");