Files
Aislo/A00_Common/formula_doc_render.ts
T
eomsangdonandClaude Opus 5.5 c80b5ae9d0 feat(802-4b-2): M02 단가산출 수식 탭 = 공용 수식 문서 화면 · 꺼진 줄 = 계산 변수 거침
- M02 SK 「수식」 탭 = M01 과 같은 공용 화면(`createDocScreen` · 길 앞머리만 `/m02/layers/{층}/sangeun/{키}/doc`) — 옛 M02 수식 나무 화면(`_Sangeun_Equation.ts` · 796-2b · 2c · 2e · 800 · 801 꼴) 걷음
  · 옵션 = SK 문서 값(저장) · [단가 갱신] · [재생성](미리 셈 → 알림 계 전 → 후 · 버리는 것 → 저장) · 「M01 새 판 있음」 배지(`formula_doc_copy.ts`)
  · 단가산출 연결 펼침 머리 「연결 / 끊음」 · [연결 끊기] · [다시 잇기] · 끊은 문서 안 줄 그 자리 고침(`formula_doc_sub.ts`) · 저장 몸 {문서, 끊음, 판}
  · 층 쓰기 권한 없음 = 읽기만(옵션 잠금 · 단추 없음 · 「읽기만」) · 판 바뀜 409 = 사본 같으면 새 판으로 한 번 더 · 저장 뒤 페이지 판 · 상세 탭 `수식` 칸 새로
  · 저장 안 한 SK(일위대가 줄 모달) = 「저장한 단가산출만」 글
- 서버 — 수식 길 답에 `연결`(화면 캐시 기록 · `M02_Doc_Screen.record`) · 연결 후보 · 표 길(층 읽기 · M01 후보 · 표 그대로 · main 등록)
- 꺼진 줄(요구 663-1 마저) — 곱 인수 하나가 선택으로 꺼진 0(만약 0 갈래 · 식 줄 기호면 그 식으로 거침)이면 숨김 · 입력 · 글 0 은 그대로
  · 판정을 순수 파일로(`common_util_formula_doc_view.ts` · Node 로 시험) · 1,518 문서 기본 옵션 꺼짐 158 → 203 줄(꺼진 줄 금액 모두 0) · GC000211 폼타이 아님 = 세 규격 줄 모두
- 화면 파일 700 줄 — 고침 함수 · 옵션 가로 · 연결 펼침을 새 파일로(`formula_doc_edit` · `_options` · `_sub`)
- 시험 test_802_4b2(5 · 헬퍼 helper_802_4b2_off.cjs) · 옛 시험 둘 고침 — test_793_3b · test_796_2b 가 걷은 옛 M02 수식 화면 글을 읽음 → 없음 확인으로

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

325 lines
10 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 {
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[] {
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, showOff = false): 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.금액 ?? []) {
if (!showOff && offLine(l, vals, lines)) continue;
from(l.수량);
from(l.단가);
}
if (doc.결과) visit(String(doc.결과));
return out;
}