feat(804-6): 옛 수식 탭 걷음 · 줄 쌓기 = 첫 「수식」 탭(요구 686-1)

- M01 단가산출 기초 · M02 단가산출 상세 보기 = 수식(줄 쌓기 · 기본) · 단가산출 상세 둘
- 줄 쌓기에 M02 사본 [단가 갱신] · [재생성](미리 셈 → 알림 → 저장 · 고침 있으면 막음) · 판 바뀜(409) 문서 그대로면 새 판으로 다시 저장 옮김
- 옛 수식 문서 화면 걷음(formula_doc_screen · defs · edit · lines · math · math_model · rows · sub · formula_doc.css) · 이미 안 부르던 796-2 수식 나무 화면(LogicLab_Equation · Defs · Var · css · M01_Logic_Equation_Model · sheet_formula_cell · ui_template_equation_defs · edit) 걷음 — 18 파일 4,860 줄
- formula_doc_render = 줄 쌓기가 쓰는 reach 만 · 옵션 가로 CSS 는 line_stack.css 로
- 남김 = 계산기 common_util_formula_doc* · 서버 길 · 옵션 · 사본 표지 · 합계 상자 · KaTeX · 시험 페이지 tools/line-stack-test
- 시험: test_796_2 지움(걷은 화면 원본 검사뿐) · 799_1 · 800_2 걷은 화면 줄만 뺌 · 799_2 · 802_4b2 · 804_2 보기 · 화면 파일 대상 바꿈 · test_804_6 새로(걷은 파일 · 부르는 곳 없음 · 첫 탭 · 사본 할일)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-08 11:09:44 +09:00
co-authored by Claude Opus 5.5
parent 04caf618bb
commit 7480aebc96
32 changed files with 169 additions and 5199 deletions
+4 -246
View File
@@ -1,267 +1,25 @@
/* =============================================================================
* 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 = 지금 탄 갈래로 닿는 기호(정의 영역에 보일 것)
* 수식 문서 닿는 기호(PLAN 802-3 · 804-6) — 줄 쌓기 산출근거(`line_stack_out.ts`)가 씀
* reach = 지금 탄 갈래로 닿는 기호(만약 안 탄 갈래 · 숨김 자리 인자 빼고)
* 옛 수식 문서 탭의 조판 그림(renderNode · 값 식 · 수 바꿈)은 804-6 에 걷음
* ========================================================================== */
import { eqText, symbolOf } from "@ui/ui_template_equation";
import {
linkNames,
splitId,
textOf,
varId,
type DocVar,
type FormulaDoc,
type Node,
} from "../common_util/common_util_formula_doc";
import { isNum } from "../common_util/common_util_formula_doc_calc";
import {
leafNum,
offLine,
takenOf,
tryParse,
type Values,
} from "../common_util/common_util_formula_doc_view";
export {
evalNode,
leafNum,
lineMap,
offLine,
offZero,
takenOf,
tryParse,
type Values,
} from "../common_util/common_util_formula_doc_view";
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);
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;
// 선택으로 꺼진 항(만약이 0 · 1 을 탐) = 안 보임(663-1) · 고침 글은 그대로
const na = leafNum(a, c.vals);
const nb = leafNum(b, c.vals);
if ((o === "+" || o === "-") && nb === 0) return hidden("(", `) ${o} ${textOf(b)}`, go(a));
if ((o === "*" || o === "/") && nb === 1) return hidden("(", `) ${o} ${textOf(b)}`, go(a));
if ((o === "+" && na === 0) || (o === "*" && na === 1))
return hidden(`${textOf(a)} ${o} (`, ")", go(b));
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[] {
function namesTaken(n: Node, vals: Values, out: string[] = []): string[] {
const add = (x: string): void => {
if (!out.includes(x)) out.push(x);
};