Files
Aislo/A00_Common/formula_doc_render.ts
T
eomsangdonandClaude Opus 5.5 54fb55487c feat(802-3): M01 단가산출 기초 수식 문서 화면 — 왼쪽 기호 식 · 오른쪽 값 식 · 빈 문서 시작 · 연결 고르기
- 공용 화면 A00_Common/formula_doc_*(M01 · 뒤에 M02 = 길 앞머리만 바꿈) — 옛 수식 나무 화면은 M01 에서 걷음(M02 는 그대로)
- 보이는 수식 그 자리 고침(공용 editEq · 전체 고르기 = 한 줄 글) · 분수 · 첨자 · 만약 = 탄 갈래만 · 반올림 자리 숨김 · 값 식 단위
- 값 식 숫자 누름 = 그 자리 값 · 기호 값 누름 = 정의 줄 · 정의 영역 = 기호 : 이름 — 연결(키 · 값 · 단위 없음 · 탄 갈래로 닿는 기호만)
- 새 기호 = 정의 필요 → 숫자 · 선택 · 입력 · 요소 · 표 · 단가산출 · 식 연결 · 기호 · 첨자 · 이름 고침 · 정의 지움
- 금액 = 경쟁사 꼴 이름 : 단가 × 수량 = 금액 · 묶음 하나 = 계 · 둘 이상 = 가. 이름 소계 + 합계 · 단가산출 연결 펼침 = 그 문서 식 · 값 식
- 셈 = 브라우저 계산기 즉시 · 연결 값 = 서버 기록 캐시(common_util_formula_doc_cache.ts ↔ master_doc_record.py) · 없는 연결만 뒤에서 미리 셈
- 서버 길 GET /api/m01/logic/doc · POST preview · save(서버가 정본으로 다시 셈 · 수식 문서 파일에 씀) · links · GET table (M01_MasterData_Doc.py)
- 연결 대상 안 고른 연결(표 · 요소 · 단가산출) = 연결 끊김 표지(파이썬 · TS 짝 가드 — 서버 500 걷음)
- 시험 test_802_3_doc_screen 11(읽기 · 빈 문서 · 저장 = 서버 다시 셈 · 관리자 아니면 막힘 · 지움 견딤 · 표지 갈래 · 후보)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 19:23:57 +09:00

374 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* formula_doc_render.ts
* 수식 문서 그림(PLAN 802-3 · 요구 660~664) — 식 나무 → 조판 수식(분수 · 첨자 · 괄호) · 화면 공용(M01 · M02)
* 기호 식(side "sym") = 기호만(한글 이름 · 표 칸 글 · DB 키 없음) · 값 식(side "val") = 기호 자리에 값 + 단위
* 만약 = 탄 갈래만(값을 알 때) · 반올림 · 올림 · 버림 자리 감싸개 숨김(662-4) — 숨긴 글은 `data-pre` · `data-post`
* 고침 글 꼴 = 공용 `serializeEq`(원자 `data-tok` · 분수 · 괄호 · 단위 뺌) — 보이는 그림이 곧 고침 자리
* evalNode = 그림용 값(탄 갈래 · 값 식) — 셈 정본은 계산기(`common_util_formula_doc_calc.ts`)
* reach = 지금 탄 갈래로 닿는 기호(정의 영역에 보일 것)
* ========================================================================== */
import { eqText, symbolOf } from "@ui/ui_template_equation";
import {
DocError,
linkNames,
parse,
splitId,
textOf,
varId,
type DocVar,
type FormulaDoc,
type Node,
} from "../common_util/common_util_formula_doc";
import { binary, cut, isNum } from "../common_util/common_util_formula_doc_calc";
export type Values = Record<string, unknown>;
const HIDE = new Set(["반올림", "올림", "버림"]);
const SHOW: Record<string, string> = {
"*": "×",
"/": "÷",
"-": "−",
"<=": "≤",
">=": "≥",
"!=": "≠",
};
const PREC: Record<string, number> = {
또는: 1,
그리고: 2,
"<": 3,
"<=": 3,
">": 3,
">=": 3,
"==": 3,
"!=": 3,
"+": 4,
"-": 4,
"*": 5,
"/": 5,
};
const prec = (n: Node): number => (n[0] === "bin" ? PREC[n[1]] : n[0] === "neg" ? 6 : 9);
/** 식 글 → 나무(못 풀면 null) */
export function tryParse(text: unknown): Node | null {
if (text === null || text === undefined || String(text).trim() === "") return null;
try {
return parse(text);
} catch (e) {
if (e instanceof DocError) return null;
throw e;
}
}
/** 그림용 값 — 모르면 undefined */
export function evalNode(n: Node, vals: Values): unknown {
const ev = (x: Node): unknown => evalNode(x, vals);
try {
switch (n[0]) {
case "num":
case "str":
return n[1];
case "var": {
const v = vals[n[1]];
return v === null ? undefined : v;
}
case "field": {
const b = ev(n[1]);
return b && typeof b === "object" ? (b as Values)[n[2]] : undefined;
}
case "neg": {
const a = ev(n[1]);
return isNum(a) ? -a : undefined;
}
case "pct": {
const a = ev(n[1]);
return isNum(a) ? a / 100 : undefined;
}
case "bin": {
const a = ev(n[2]);
if (a === undefined) return undefined;
if (n[1] === "그리고" && !a) return a;
if (n[1] === "또는" && a) return a;
const b = ev(n[3]);
if (b === undefined) return undefined;
if (n[1] === "그리고" || n[1] === "또는") return b;
const got = binary(n[1], a, b);
return typeof got === "number" && !Number.isFinite(got) ? undefined : got;
}
case "call": {
const [, name, items] = n;
if (name === "만약") {
const t = items.length === 3 ? ev(items[0]) : undefined;
return t === undefined ? undefined : ev(t ? items[1] : items[2]);
}
const xs = items.map(ev);
if (!xs.every(isNum)) return undefined;
if (name === "최소") return Math.min(...(xs as number[]));
if (name === "최대") return Math.max(...(xs as number[]));
return cut(xs[0] as number, { 방법: name, 자리: Math.trunc((xs[1] as number) ?? 0) });
}
}
} catch {
return undefined;
}
}
/** 만약 — 탄 갈래 차례(1 참 · 2 거짓 · 0 모름) */
export function takenOf(n: Node, vals: Values): 0 | 1 | 2 {
if (n[0] !== "call" || n[1] !== "만약" || n[2].length !== 3) return 0;
const t = evalNode(n[2][0], vals);
return t === undefined ? 0 : t ? 1 : 2;
}
export interface Ctx {
side: "sym" | "val";
vals: Values;
vars: Map<string, DocVar>;
/** ÷ 를 분수 대신 한 줄로(금액 줄 · 경쟁사 꼴) */
inline?: boolean;
/** 묶음 값의 비목(금액 묶음 줄 노 · 재 · 경 줄) */
cost?: string;
/** 수 마디 → 차례(값 식 숫자 고침) */
lits?: Map<Node, number>;
}
const span = (cls: string, text?: string): HTMLSpanElement => {
const s = document.createElement("span");
s.className = cls;
if (text !== undefined) s.textContent = text;
return s;
};
/** 기호 글(`Q_시공^표`) → 기호 그림(첨자) */
export function symNode(id: string): HTMLElement {
const [기호, 아래, 위] = splitId(id);
return symbolOf({ 기호, 아래, 위, 열쇠: id });
}
/** 값 글 + 단위 */
export function valueNode(value: unknown, unit = ""): HTMLElement {
const box = span("fdoc__val");
box.append(span("ui-eq__num", eqText(value)));
if (unit) box.append(span("ui-eq__unit", ` ${unit}`));
return box;
}
const paren = (inner: HTMLElement): HTMLElement => {
const p = span("ui-eq__paren");
p.append(span("ui-eq__inner"));
p.firstElementChild!.append(inner);
return p;
};
const op = (o: string): HTMLElement => span("ui-eq__op", SHOW[o] ?? o);
/** 감쌈 — 그림엔 안 보이는 앞 · 뒤 글(고침 때 그대로 되돌림) */
const hidden = (pre: string, post: string, inner: HTMLElement): HTMLElement => {
const w = span("fdoc__wrap");
w.dataset.pre = pre;
w.dataset.post = post;
w.append(inner);
return w;
};
function varNode(id: string, c: Ctx): HTMLElement {
if (c.side === "sym") {
const a = span("ui-eq__var fdoc__atom");
a.dataset.tok = id;
a.dataset.var = id;
a.append(symNode(id));
return a;
}
let v = c.vals[id];
if (v && typeof v === "object" && c.cost) v = (v as Values)[c.cost];
const a = span("ui-eq__var fdoc__atom is-val");
a.dataset.tok = id;
a.dataset.var = id;
if (v === null || v === undefined || typeof v === "object") {
a.classList.add("is-missing"); // 값 없음 · 정의 필요 — 기호 그대로(빨강)
a.append(symNode(id));
} else a.append(valueNode(v, c.vars.get(id)?.단위 ?? ""));
return a;
}
/** 나무 → 조판 수식 */
export function renderNode(n: Node, c: Ctx): HTMLElement {
const go = (x: Node): HTMLElement => renderNode(x, c);
switch (n[0]) {
case "num": {
const s = span("ui-eq__num fdoc__lit", eqText(n[1]));
const at = c.lits?.get(n);
if (at !== undefined) s.dataset.lit = String(at);
return s;
}
case "str": {
const s = span("ui-eq__str fdoc__atom", n[1]);
s.dataset.tok = `'${n[1]}'`;
return s;
}
case "var":
return varNode(n[1], c);
case "field": {
if (c.side === "val" && n[1][0] === "var") {
const v = c.vals[n[1][1]];
const got = v && typeof v === "object" ? (v as Values)[n[2]] : undefined;
if (isNum(got)) {
const a = span("ui-eq__var fdoc__atom is-val");
a.dataset.tok = textOf(n);
a.dataset.var = n[1][1];
a.append(valueNode(got));
return a;
}
}
const a = span("ui-eq__var fdoc__atom");
a.dataset.tok = textOf(n);
if (n[1][0] === "var") a.dataset.var = n[1][1];
const base = n[1][0] === "var" ? symNode(n[1][1]) : span("", textOf(n[1]));
a.append(base, span("ui-eq__script", `.${n[2]}`));
return a;
}
case "neg": {
const s = span("ui-eq__body");
s.append(op("-"), n[1][0] === "bin" ? paren(go(n[1])) : go(n[1]));
return s;
}
case "pct": {
const s = span("ui-eq__body");
s.append(prec(n[1]) < 9 ? paren(go(n[1])) : go(n[1]), span("ui-eq__op", "%"));
return s;
}
case "call": {
const [, name, items] = n;
if (name === "만약") {
const pick = takenOf(n, c.vals);
if (pick) {
const [t, a, b] = items.map(textOf);
return pick === 1
? hidden(`만약(${t}, `, `, ${b})`, go(items[1]))
: hidden(`만약(${t}, ${a}, `, ")", go(items[2]));
}
}
if (HIDE.has(name) && items.length >= 1 && items.length <= 2) {
const post = items.length === 2 ? `, ${textOf(items[1])})` : ")";
return hidden(`${name}(`, post, go(items[0]));
}
const s = span("ui-eq__body");
const inner = span("ui-eq__body");
items.forEach((x, i) => {
if (i) inner.append(span("ui-eq__op", ","));
inner.append(go(x));
});
s.append(span("ui-eq__fn", name), paren(inner));
return s;
}
case "bin": {
const [, o, a, b] = n;
if (o === "/" && !c.inline) {
const f = span("ui-eq__frac");
const top = span("ui-eq__top");
const low = span("ui-eq__bottom");
top.append(go(a));
low.append(go(b));
f.append(top, low);
return f;
}
const mine = PREC[o];
const s = span("ui-eq__body");
const left = go(a);
const right = go(b);
s.append(
prec(a) < mine || (mine === 3 && prec(a) === 3) ? paren(left) : left,
op(o),
prec(b) <= mine ? paren(right) : right,
);
return s;
}
}
}
/** 수 마디 차례(나무 걸음 차례) */
export function litsOf(n: Node): Map<Node, number> {
const out = new Map<Node, number>();
const go = (x: Node): void => {
if (x[0] === "num") out.set(x, out.size);
if (x[0] === "bin") {
go(x[2]);
go(x[3]);
} else if (x[0] === "neg" || x[0] === "pct" || x[0] === "field") go(x[1]);
else if (x[0] === "call") x[2].forEach(go);
};
go(n);
return out;
}
/** 차례 번째 수를 바꾼 식 글 */
export function swapLit(text: string, at: number, now: number): string {
const n = tryParse(text);
if (!n) return text;
const lits = litsOf(n);
for (const [node, i] of lits) if (i === at) node[1] = now;
for (const [node] of lits) (node as [string, number, string])[2] = String(node[1]);
return textOf(n);
}
/** 탄 갈래로 부르는 이름(만약 안 탄 갈래 · 숨김 자리 인자 빼고) */
export function namesTaken(n: Node, vals: Values, out: string[] = []): string[] {
const add = (x: string): void => {
if (!out.includes(x)) out.push(x);
};
switch (n[0]) {
case "var":
add(n[1]);
break;
case "bin":
namesTaken(n[2], vals, out);
namesTaken(n[3], vals, out);
break;
case "neg":
case "pct":
case "field":
namesTaken(n[1], vals, out);
break;
case "call": {
const pick = takenOf(n, vals);
const items = pick ? [n[2][0], n[2][pick]] : n[2];
items.forEach((x) => namesTaken(x, vals, out));
break;
}
}
return out;
}
const RESERVED_MONEY = new Set(["노무비", "재료비", "경비", "번지", "단계"]);
/** 정의 영역에 보일 기호 — 식 줄 · 금액 줄 · 결과에서 지금 탄 갈래로 닿는 것(보이는 차례) */
export function reach(doc: FormulaDoc, vals: Values): string[] {
const vars = new Map((doc.변수 ?? []).map((v) => [varId(v), v]));
const lines = new Map((doc.식 ?? []).map((l) => [String(l.기호 ?? ""), l.식]));
const money = new Set((doc.금액 ?? []).map((l) => String(l.기호 ?? "")).filter(Boolean));
const out: string[] = [];
const visit = (name: string): void => {
if (!name || out.includes(name) || RESERVED_MONEY.has(name) || money.has(name)) return;
out.push(name);
if (lines.has(name)) {
const n = tryParse(lines.get(name));
if (n) namesTaken(n, vals).forEach(visit);
}
const v = vars.get(name);
if (v?.연결 && v.연결.갈래 !== "식") linkNames(v.연결).forEach(visit);
};
const from = (text: unknown): void => {
const n = tryParse(text);
if (n) namesTaken(n, vals).forEach(visit);
};
for (const l of doc.식 ?? []) {
visit(String(l.기호 ?? ""));
from(l.식);
}
for (const l of doc.금액 ?? []) {
from(l.수량);
from(l.단가);
}
if (doc.결과) visit(String(doc.결과));
return out;
}