- 663-1: 수량 · 단가가 만약으로 0 을 탄 금액 줄 = 안 보임(GC000211 폼타이 9.5 · 7.9 ㎜) · 규격 바꾸면 그 줄만 · [선택으로 꺼진 줄 보기] = 고침 모드에서만 · 정의 영역도 꺼진 줄 기호 뺌 1,518 문서 금액 줄 5,912 중 기본 옵션에서 꺼짐 145 줄(107 문서) · 옵션에 따라 꺼질 수 있는 줄 305 줄(141 문서) - 식 안 만약이 0 · 1 을 타면 그 항(+ 0 · × 1)도 안 보임 — 고침 글은 그대로 - 수 하나 · 기호 하나 줄 값 식 = 한 번만(「= 1 = 1」 없음) · 수량 없음 「× 1」 자리는 줄에 마우스 올릴 때만 - 옮김 slim(master_doc_convert) — + 0 · × 1 · ÷ 1 지움 · A × k ÷ 100 → A × k% · 빌더 다시(목록 xlsx 는 안 씀) · 대조 그대로(기본 1,519 같음 · 옵션 다름 1 · 저장 1,518) · 31 파일 글만 - 시험 test_802_3 + 군더더기 없음 1(12) · 802_1 · 802_2 통과 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
399 lines
13 KiB
TypeScript
399 lines
13 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 {
|
||
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;
|
||
}
|
||
}
|
||
|
||
/** 만약 사슬이 탄 끝 갈래가 수 하나면 그 수(만약이 아니거나 모르면 undefined) */
|
||
export function leafNum(n: Node, vals: Values): number | undefined {
|
||
let at = n;
|
||
let hop = 0;
|
||
for (let pick = takenOf(at, vals); pick; pick = takenOf(at, vals), hop++)
|
||
at = (at as ["call", string, Node[]])[2][pick];
|
||
return hop && at[0] === "num" ? at[1] : undefined;
|
||
}
|
||
|
||
/** 선택으로 꺼진 금액 줄 — 수량 · 단가가 만약으로 0 을 탐(663-1 · 고침 모드에서만 보임) */
|
||
export function offLine(line: { 수량?: unknown; 단가?: unknown }, vals: Values): boolean {
|
||
return [line.수량, line.단가].some((t) => {
|
||
const n = tryParse(t);
|
||
return !!n && leafNum(n, vals) === 0;
|
||
});
|
||
}
|
||
|
||
/** 만약 — 탄 갈래 차례(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;
|
||
// 선택으로 꺼진 항(만약이 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)) continue;
|
||
from(l.수량);
|
||
from(l.단가);
|
||
}
|
||
if (doc.결과) visit(String(doc.결과));
|
||
return out;
|
||
}
|