Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
285 lines
11 KiB
TypeScript
285 lines
11 KiB
TypeScript
/* =============================================================================
|
||
* M02_DF_FormulaView.ts
|
||
* 함수 상자 「식 보기」 — 결과 칸과 그 칸이 기대는 중간 칸의 식을 칸 주소 → 연결 알약 이름으로 · ×, ÷ · 값 들어간 식 · = 결과 ·
|
||
* IF 는 만약 / 아니면 줄 + 지금 타는 갈래(now) · 앞 절반은 DOM 을 모름(시험이 Node 로 돌림) · 상세 내용 함수 판과 왼쪽 텍스트 수식이 함께 씀
|
||
* 모양은 `docs/raw/M01_쉬운단가/흐름도_적용_정함.md` 약속 8 · 글 [한, 영] 짝은 아래 TEXT
|
||
* ========================================================================== */
|
||
|
||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||
import { el } from "@ui/ui_template_elements";
|
||
import { parseA1 } from "../../A00_Common/spreadsheet/spreadsheet_address";
|
||
import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc";
|
||
import { ifBody, splitArgs } from "../../A00_Common/spreadsheet/spreadsheet_condition_text";
|
||
import type { CalcValue } from "../../A00_Common/spreadsheet/spreadsheet_types";
|
||
import type { CfBox } from "./M02_DF_Model";
|
||
|
||
export type CellScalar = number | string | boolean | null;
|
||
|
||
export interface FormulaBranch {
|
||
when: string;
|
||
then: string;
|
||
/** 지금 타는 갈래 */
|
||
now: boolean;
|
||
}
|
||
|
||
export interface FormulaLine {
|
||
/** 이 식이 든 칸의 이름(연결 알약 이름 · 없으면 칸 주소) */
|
||
name: string;
|
||
/** 이름으로 쓴 식 — 「석공량 × (1 + 증가율 ÷ 100)」 */
|
||
named: string;
|
||
/** 값 넣은 식 — 「0.15 × (1 + 4 ÷ 100) = 0.156」 */
|
||
valued: string;
|
||
/** IF 면 만약 / 아니면 줄 */
|
||
branches?: FormulaBranch[];
|
||
}
|
||
|
||
const TEXT = {
|
||
Title: ["식 보기", "Formulas"],
|
||
Empty: ["식이 든 결과 칸이 아직 없음", "No result cell with a formula yet"],
|
||
Else: ["아니면", "Otherwise"],
|
||
If: ["만약", "If"],
|
||
Now: ["지금", "now"],
|
||
} as const;
|
||
|
||
const tv = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
|
||
|
||
/* ── 칸 · 이름 ──────────────────────────────────────────────────────────── */
|
||
|
||
const REF = /(?<![A-Za-z0-9_.'!])\$?([A-Z]{1,3})\$?(\d+)(?![A-Za-z0-9_(])/g;
|
||
|
||
/** 따옴표 안 글은 그대로 두고 바깥 글만 바꿈 */
|
||
function outside(text: string, fn: (part: string) => string): string {
|
||
return text
|
||
.split(/("(?:[^"]|"")*")/)
|
||
.map((p, i) => (i % 2 ? p : fn(p)))
|
||
.join("");
|
||
}
|
||
|
||
const upper = (a1: string): string => a1.toUpperCase().replace(/\$/g, "");
|
||
|
||
/** 칸 주소 → 이름 — 받는 알약 · 변수 · 결과 칸 이름(연결 없는 칸은 주소 그대로) */
|
||
export function cellNames(box: CfBox): Record<string, string> {
|
||
const f = box.func;
|
||
const out: Record<string, string> = {};
|
||
if (!f) return out;
|
||
for (const i of f.inputs) if (i.cell) out[upper(i.cell)] = i.port;
|
||
for (const v of f.variables) if (v.cell) out[upper(v.cell)] = v.name;
|
||
if (f.output.cell) out[upper(f.output.cell)] = f.output.name;
|
||
return out;
|
||
}
|
||
|
||
const refsIn = (formula: string): string[] => {
|
||
const found: string[] = [];
|
||
outside(formula, (p) => p.replace(REF, (m, c: string, r: string) => (found.push(`${c}${r}`), m)));
|
||
return found;
|
||
};
|
||
|
||
const OPS: [RegExp, string][] = [
|
||
[/\*/g, " × "],
|
||
[/\//g, " ÷ "],
|
||
[/<>/g, " ≠ "],
|
||
[/>=/g, " ≥ "],
|
||
[/<=/g, " ≤ "],
|
||
[/(?<![<>≠≥≤])=/g, " = "],
|
||
[/\+/g, " + "],
|
||
[/(?<=[\w가-힣)])-(?=[\w가-힣(])/g, " − "],
|
||
];
|
||
|
||
/** 보기 좋게 — × ÷ ≠ ≥ ≤ 로 */
|
||
function pretty(text: string): string {
|
||
return outside(text, (p) => OPS.reduce((s, [re, to]) => s.replace(re, to), p))
|
||
.replace(/\s{2,}/g, " ")
|
||
.trim();
|
||
}
|
||
|
||
const fmt = (n: number): string =>
|
||
Number(n.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
|
||
|
||
function showScalar(v: CellScalar | undefined): string {
|
||
if (v === undefined || v === null) return "0";
|
||
if (typeof v === "number") return fmt(v);
|
||
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
|
||
return `"${v}"`;
|
||
}
|
||
|
||
/** 이름으로 쓴 식 */
|
||
export function namedText(formula: string, names: Record<string, string>): string {
|
||
return pretty(
|
||
outside(formula, (p) =>
|
||
p.replace(REF, (_m, c: string, r: string) => names[`${c}${r}`] ?? `${c}${r}`),
|
||
),
|
||
);
|
||
}
|
||
|
||
/** 값 넣은 식(값이 문자면 따옴표) */
|
||
export function valuedText(formula: string, values: Record<string, CellScalar>): string {
|
||
return pretty(
|
||
outside(formula, (p) =>
|
||
p.replace(REF, (_m, c: string, r: string) => showScalar(values[`${c}${r}`])),
|
||
),
|
||
);
|
||
}
|
||
|
||
/* ── 갈래 ───────────────────────────────────────────────────────────────── */
|
||
|
||
const num = (v: CellScalar | undefined): number | null => {
|
||
if (typeof v === "number") return v;
|
||
if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) return Number(v);
|
||
return null;
|
||
};
|
||
|
||
/** 조건 한 줄 `왼 비교 오른` 의 참거짓 — 못 읽으면 null */
|
||
function holds(cond: string, values: Record<string, CellScalar>): boolean | null {
|
||
const m = /^\s*(.+?)\s*(>=|<=|<>|=|>|<)\s*(.+?)\s*$/.exec(cond);
|
||
if (!m) return null;
|
||
const read = (t: string): CellScalar | undefined => {
|
||
const x = t.trim();
|
||
const cell = /^\$?([A-Z]{1,3})\$?(\d+)$/.exec(x);
|
||
if (cell) return values[`${cell[1]}${cell[2]}`];
|
||
const q = /^"((?:[^"]|"")*)"$/.exec(x);
|
||
if (q) return q[1].replace(/""/g, '"');
|
||
return Number.isFinite(Number(x)) ? Number(x) : undefined;
|
||
};
|
||
const a = read(m[1]);
|
||
const b = read(m[3]);
|
||
if (a === undefined || b === undefined) return null;
|
||
const [l, r] = num(a) !== null && num(b) !== null ? [num(a)!, num(b)!] : [String(a), String(b)];
|
||
switch (m[2]) {
|
||
case "=":
|
||
return l === r;
|
||
case "<>":
|
||
return l !== r;
|
||
case ">=":
|
||
return l >= r;
|
||
case "<=":
|
||
return l <= r;
|
||
case ">":
|
||
return l > r;
|
||
default:
|
||
return l < r;
|
||
}
|
||
}
|
||
|
||
/** IF 사슬 → 갈래들 — IF 가 아니면 undefined */
|
||
function branchesOf(
|
||
formula: string,
|
||
names: Record<string, string>,
|
||
values: Record<string, CellScalar>,
|
||
): FormulaBranch[] | undefined {
|
||
const out: FormulaBranch[] = [];
|
||
let rest = formula;
|
||
let taken = false;
|
||
for (;;) {
|
||
const body = ifBody(rest);
|
||
if (body === null) break;
|
||
const a = splitArgs(body);
|
||
if (a.length !== 3) return undefined;
|
||
const ok = holds(a[0], values) === true && !taken;
|
||
if (ok) taken = true;
|
||
out.push({ when: namedText(a[0], names), then: namedText(a[1], names), now: ok });
|
||
rest = a[2];
|
||
}
|
||
if (!out.length) return undefined;
|
||
out.push({ when: tv("Else"), then: namedText(rest, names), now: !taken });
|
||
return out;
|
||
}
|
||
|
||
/* ── 줄 ─────────────────────────────────────────────────────────────────── */
|
||
|
||
/** 식 보기 줄들 — 결과 칸이 기대는 칸의 식이 먼저 · 결과 칸이 마지막 */
|
||
export function formulaLines(box: CfBox, values: Record<string, CellScalar>): FormulaLine[] {
|
||
const f = box.func;
|
||
if (!f?.output.cell) return [];
|
||
const names = cellNames(box);
|
||
const cells = f.sheet.칸;
|
||
const order: string[] = [];
|
||
const seen = new Set<string>();
|
||
const visit = (a1: string): void => {
|
||
const key = upper(a1);
|
||
if (seen.has(key)) return;
|
||
seen.add(key);
|
||
const formula = cells[key]?.식;
|
||
if (formula === undefined) return;
|
||
for (const r of refsIn(formula)) visit(r);
|
||
order.push(key);
|
||
};
|
||
visit(f.output.cell);
|
||
return order.map((key) => {
|
||
const formula = cells[key].식 as string;
|
||
const result = showScalar(values[key]);
|
||
const branches = branchesOf(formula, names, values);
|
||
return {
|
||
name: names[key] ?? key,
|
||
named: namedText(formula, names),
|
||
valued: `${valuedText(formula, values)} = ${result}`,
|
||
...(branches ? { branches } : {}),
|
||
};
|
||
});
|
||
}
|
||
|
||
/** 시트 칸 값 — 받는 알약 칸에 들어온 값(arrived) · 변수 예시값을 넣고 엔진으로 셈 */
|
||
export function cellValuesOf(
|
||
box: CfBox,
|
||
arrived: Record<string, unknown> | undefined,
|
||
): Record<string, CellScalar> {
|
||
const f = box.func;
|
||
if (!f) return {};
|
||
const sheet = structuredClone(f.sheet);
|
||
const scalar = (v: unknown): number | string | boolean | undefined =>
|
||
typeof v === "number" || typeof v === "string" || typeof v === "boolean" ? v : undefined;
|
||
const put = (cell: string, v: number | string | boolean | undefined): void => {
|
||
if (!parseA1(cell) || v === undefined) return;
|
||
sheet.칸[upper(cell)] = { 값: v };
|
||
};
|
||
for (const i of f.inputs) if (i.cell) put(i.cell, scalar(arrived?.[i.port]));
|
||
for (const v of f.variables) if (v.cell && v.example !== null) put(v.cell, v.example);
|
||
const calc = recalcWorkbook({ 종류: "통합문서", 판: 1, 열: "DF", 서식: [{}], 시트: [sheet] });
|
||
const out: Record<string, CellScalar> = {};
|
||
for (const [key, cell] of Object.entries(sheet.칸)) {
|
||
const c: CalcValue | undefined = calc[sheet.id]?.[key];
|
||
out[key] = cell.식 === undefined ? (cell.값 ?? null) : c ? fromCalc(c) : null;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function fromCalc(v: CalcValue): CellScalar {
|
||
if ("수" in v) return Number(v.수);
|
||
if ("글" in v) return v.글;
|
||
if ("참" in v) return v.참;
|
||
return v.오류;
|
||
}
|
||
|
||
/* ── 그리기 ─────────────────────────────────────────────────────────────── */
|
||
|
||
/** 식 보기 그리기 — 줄마다 이름 식 · 값 식 · 갈래(지금 갈래는 ▶ 와 「지금」 글) */
|
||
export function renderFormulaView(host: HTMLElement, lines: FormulaLine[]): void {
|
||
host.classList.add("m02-df-formula");
|
||
host.replaceChildren(
|
||
el("strong", { className: "m02-df-formula__title", text: tv("Title") }),
|
||
...(lines.length
|
||
? lines.map((l) =>
|
||
el("div", {
|
||
className: "m02-df-formula__line",
|
||
children: [
|
||
el("div", { children: [el("b", { text: `${l.name} = ` }), l.named] }),
|
||
el("div", { className: "m02-df-formula__valued", text: l.valued }),
|
||
...(l.branches
|
||
? [
|
||
el("ul", {
|
||
className: "m02-df-formula__branches",
|
||
children: l.branches.map((b, i) =>
|
||
el("li", {
|
||
className: b.now ? "is-now" : "",
|
||
text: `${b.now ? "▶ " : ""}${i < l.branches!.length - 1 ? `${tv("If")} ${b.when} → ` : `${b.when} → `}${b.then}${b.now ? ` (${tv("Now")})` : ""}`,
|
||
}),
|
||
),
|
||
}),
|
||
]
|
||
: []),
|
||
],
|
||
}),
|
||
)
|
||
: [el("p", { className: "m02-df-formula__empty", text: tv("Empty") })]),
|
||
);
|
||
}
|