- 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>
325 lines
10 KiB
TypeScript
325 lines
10 KiB
TypeScript
/* =============================================================================
|
||
* 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;
|
||
}
|