- 걷음: 43784a4c0 의 옛 수식 나무 위 고침(서버 · 화면 · css) = 그 앞 판으로 되돌림
- 남김: master_equation_sign.py · ui_template_equation_text.ts · ui_template_equation_edit.ts(다시 쓸 조각 · 지금 안 부름) · text 는 `입력글` 을 제 타입에
- 지움: test_801_1_equation_inline.py — 걷은 옛 나무 고침을 재는 시험
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
341 lines
12 KiB
TypeScript
341 lines
12 KiB
TypeScript
/* =============================================================================
|
||
* ui_template_equation.ts
|
||
* 부품 「수식」(796-2 · 요구 655) — 수식 나무(서버 `master_equation.py` 나무 JSON)를 PPT 수식 꼴로 그림.
|
||
* 꼴 = 분수(가로 줄 안 세로 쌓기 · 분수선) · 위 · 아래첨자 · × ÷ + − · 비교 · 괄호(키 큰 곡선) · % · 단위 ·
|
||
* 조건 칩(만약 → 걸린 쪽만) · 함수 · 하위 로직 칩(블록 · 접힘은 `ui_template_equation_line.ts`)
|
||
* 크기 = 글자 `--text-body` · 첨자 0.7 배 · 분수 안 글 같은 크기 · 변수 칩 높이는 글 줄
|
||
* 상태 = 고르는 변수 `is-pick`(눌림 가능 · 머리 `data-size` 없음 — 글 속 부품) · 걸리지 않은 가지는 마우스 글로만
|
||
* 쓰는 법 = `renderEq(tree, { onVar, onBlock })` → 인라인 요소 · 줄 · 풀이 · 블록 = `ui_template_equation_line.ts`
|
||
* 대신하는 옛 변종 = `createCalcFormula` 의 `^…^` · `_…_` 글(분수 없음 · 그 부품은 그대로 둠)
|
||
* ========================================================================== */
|
||
|
||
import "./ui_template_equation.css";
|
||
|
||
/** 서버 나무 마디 — 꼴마다 쓰는 칸이 다름(변수 `위` · `아래` 는 첨자 글 · 분수 `위` · `아래` 는 마디) */
|
||
export interface EqNode {
|
||
꼴: string;
|
||
글?: string;
|
||
따옴?: boolean;
|
||
값?: unknown;
|
||
열쇠?: string;
|
||
이름?: string;
|
||
기호?: string;
|
||
위?: string | EqNode;
|
||
아래?: string | EqNode;
|
||
단위?: string;
|
||
갈래?: string;
|
||
식?: string;
|
||
칸?: string | null;
|
||
표?: string;
|
||
표이름?: string;
|
||
행?: Record<string, unknown>;
|
||
조건식?: Record<string, string>;
|
||
좌?: EqNode;
|
||
우?: EqNode;
|
||
안?: EqNode;
|
||
조건?: EqNode;
|
||
참?: EqNode;
|
||
거짓?: EqNode;
|
||
걸림?: number;
|
||
이름식?: EqNode;
|
||
인자?: EqNode[];
|
||
묶음?: Record<string, unknown> | null;
|
||
결과단위?: string;
|
||
나무?: EqTree | null;
|
||
사슬?: boolean;
|
||
}
|
||
|
||
/** 단가산출 소계 줄(799-1) — 갈래 블록 | 자원 | 덧줄 | 율 · M02 SK(796-2c) 는 셈 답 한 줄마다 + 상태 */
|
||
export interface EqSumRow {
|
||
갈래: string;
|
||
이름: string;
|
||
노무비: unknown;
|
||
재료비: unknown;
|
||
경비: unknown;
|
||
계: unknown;
|
||
/** M02 SK — 바탕 | 덮음 | 더함 | 뺌 (M01 은 없음) */
|
||
상태?: string;
|
||
수량?: unknown;
|
||
/** 덮음 갈래(품 · 단가 · 상수 · 표값 · 식) */
|
||
덮개?: string[];
|
||
/** 덮음 · 뺌 — 고치기 전 금액 */
|
||
원래?: unknown;
|
||
까닭?: string;
|
||
/** 합계 밖 줄(엔진이 비목 합에서 뺌) — 행 금액은 0 · 밖금액에 원래 금액 */
|
||
합밖?: boolean;
|
||
밖금액?: unknown;
|
||
/** 더한 기초의 수식(접힘 블록) */
|
||
나무?: EqTree | null;
|
||
/** 더한 기초 차례(M02 SK 더한줄 · 연결 끊기 796-2e) */
|
||
블록?: number;
|
||
/** 연결을 끊은 기초(이 SK 안 자기 사본) */
|
||
끊음?: boolean;
|
||
}
|
||
|
||
/** 소계 표 덧단추 자리(796-2e) — 페이지가 줄 머리 단추 · 접힘 블록 속을 바꿔 끼움 */
|
||
export interface EqSumHooks {
|
||
head?: (row: EqSumRow, at: number) => HTMLElement[];
|
||
body?: (row: EqSumRow, at: number) => HTMLElement | null;
|
||
}
|
||
|
||
/** 로직 하나의 수식(줄 · 변수표 · 합계 · 소계) */
|
||
export interface EqTree {
|
||
열쇠: string;
|
||
이름: string;
|
||
결과단위: string;
|
||
줄: EqLine[];
|
||
변수표: EqVarRow[];
|
||
합계: Record<string, unknown>;
|
||
/** 799-1 — 하위 로직 블록마다 노 · 재 · 경 소계 + 끝수(합계 − 줄 합) · 결과 로직은 없음 */
|
||
소계?: { 줄: EqSumRow[]; 끝수: Record<string, unknown>; 끝수까닭?: string };
|
||
/** M02 SK — 셈 답이 안 서서 바탕 줄 합계를 보일 때의 까닭 */
|
||
답까닭?: string;
|
||
/** 800-2 — 오른쪽 변수 정의 영역(고른 옵션 갈래만 · 같은 기호 한 줄 · 값 · 단위 · DB 열쇠 없음) */
|
||
정의?: EqDef[];
|
||
}
|
||
|
||
/** 변수 정의 한 줄 — 기호 : 이름 — 연결(사람 말 · 여럿이면 줄 바꿈) */
|
||
export interface EqDef {
|
||
/** 변수 열쇠(입력 · 중간 변수만 · 찾기 · 요소는 빈 글) — 화면 글에는 안 나옴 */
|
||
열쇠: string;
|
||
기호: string;
|
||
위: string;
|
||
아래: string;
|
||
이름: string;
|
||
갈래: string;
|
||
연결: string[];
|
||
/** 이름 · 기호 · 첨자 고침 · 지움이 되는 변수 */
|
||
고침: boolean;
|
||
/** 어느 식에도 안 쓰이는 입력(지울 수 있음) */
|
||
안씀?: boolean;
|
||
}
|
||
|
||
export interface EqVarRow {
|
||
열쇠: string;
|
||
이름: string;
|
||
기호: string;
|
||
위: string;
|
||
아래: string;
|
||
단위: string;
|
||
갈래: string;
|
||
기본값: unknown;
|
||
설명: string;
|
||
표시?: number;
|
||
}
|
||
|
||
export interface EqLine {
|
||
갈래: string;
|
||
열쇠: string;
|
||
차례?: number;
|
||
식: string;
|
||
입력글?: string;
|
||
까닭?: string;
|
||
결과?: EqNode;
|
||
이름식?: EqNode;
|
||
값식?: EqNode;
|
||
값?: unknown;
|
||
글?: string;
|
||
표시?: number;
|
||
비목?: unknown;
|
||
/** M02 SK 고친 칸(796-2c) — 갈래(식 · 상수 · 표값 · 품 · 단가 · 변수줄) · 원래 → 지금 · 뺀줄 */
|
||
표지?: string[];
|
||
원래?: unknown;
|
||
지금?: unknown;
|
||
뺌?: boolean;
|
||
}
|
||
|
||
export interface EqHandlers {
|
||
/** 변수 칩을 누름(없으면 눌리지 않음) */
|
||
onVar?: (node: EqNode) => void;
|
||
/** 하위 로직 칩을 누름 — 줄 아래 블록을 펴고 접음 */
|
||
onBlock?: (node: EqNode) => void;
|
||
/** 블록 번호 매기기용 — 이 식 안 블록 목록(부르는 쪽이 비우고 씀) */
|
||
blocks?: EqNode[];
|
||
}
|
||
|
||
const SHOW: Record<string, string> = {
|
||
"==": "=",
|
||
"!=": "≠",
|
||
"<=": "≤",
|
||
">=": "≥",
|
||
"*": "×",
|
||
"/": "÷",
|
||
"-": "−",
|
||
};
|
||
const NUM = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 6 });
|
||
|
||
const span = (className: string, ...kids: (Node | string)[]): HTMLSpanElement => {
|
||
const node = document.createElement("span");
|
||
node.className = className;
|
||
node.append(...kids);
|
||
return node;
|
||
};
|
||
|
||
/** 값 → 글(수는 쉼표 · 6 자리까지) */
|
||
export function eqText(value: unknown): string {
|
||
if (value === null || value === undefined || value === "") return "";
|
||
const n = typeof value === "number" ? value : Number(value);
|
||
return typeof value !== "boolean" && Number.isFinite(n) && String(value).trim() !== ""
|
||
? NUM.format(n)
|
||
: String(value);
|
||
}
|
||
|
||
/** 변수 글(기호 · 아래 · 위) — 첨자가 없으면 기호만 */
|
||
export function symbolOf(node: Pick<EqNode, "열쇠" | "기호" | "위" | "아래">): HTMLElement {
|
||
const sign = node.기호 || node.열쇠 || "";
|
||
const box = span("ui-eq__sym", sign);
|
||
if (/^[A-Za-z]{1,2}$/.test(sign)) box.classList.add("is-latin");
|
||
if (typeof node.아래 === "string" && node.아래) box.append(subOf("sub", node.아래));
|
||
if (typeof node.위 === "string" && node.위) box.append(subOf("sup", node.위));
|
||
return box;
|
||
}
|
||
|
||
const subOf = (tag: "sub" | "sup", text: string): HTMLElement => {
|
||
const node = document.createElement(tag);
|
||
node.className = "ui-eq__script";
|
||
node.textContent = text;
|
||
return node;
|
||
};
|
||
|
||
/** 한 줄 글(마우스 글 · 검사용) — 변수는 기호 · 값 꼴 */
|
||
export function plainEq(node: EqNode | undefined): string {
|
||
if (!node) return "";
|
||
switch (node.꼴) {
|
||
case "수":
|
||
case "글":
|
||
return node.따옴 ? `'${node.글 ?? ""}'` : (node.글 ?? "");
|
||
case "변수": {
|
||
const sign = node.기호 || node.열쇠 || "";
|
||
const low = typeof node.아래 === "string" && node.아래 ? `_${node.아래}` : "";
|
||
const up = typeof node.위 === "string" && node.위 ? `^${node.위}` : "";
|
||
return sign + low + up;
|
||
}
|
||
case "블록":
|
||
return node.이름 || node.열쇠 || "";
|
||
case "연산":
|
||
return `${plainEq(node.좌)} ${SHOW[node.기호 ?? ""] ?? node.기호} ${plainEq(node.우)}`;
|
||
case "분수":
|
||
return `(${plainEq(node.위 as EqNode)}) ÷ (${plainEq(node.아래 as EqNode)})`;
|
||
case "괄호":
|
||
return `(${plainEq(node.안)})`;
|
||
case "퍼센트":
|
||
return `${plainEq(node.안)}%`;
|
||
case "음":
|
||
return `−${plainEq(node.안)}`;
|
||
case "조건":
|
||
return `만약(${plainEq(node.조건)}, ${plainEq(node.참)}, ${plainEq(node.거짓)})`;
|
||
case "함수":
|
||
return `${node.이름}(${(node.인자 ?? []).map(plainEq).join(", ")})`;
|
||
default:
|
||
return "";
|
||
}
|
||
}
|
||
|
||
/** 변수 한 개 — 눌리면 `onVar` */
|
||
function variable(node: EqNode, h: EqHandlers): HTMLElement {
|
||
const box = span("ui-eq__var", symbolOf(node));
|
||
box.dataset.kind = node.갈래 ?? "";
|
||
box.dataset.eqVar = node.열쇠 ?? "";
|
||
const value = eqText(node.값);
|
||
box.title = [node.이름 || node.열쇠, value ? `= ${value}${node.단위 ? ` ${node.단위}` : ""}` : ""]
|
||
.filter(Boolean)
|
||
.join(" ");
|
||
if (h.onVar) {
|
||
box.classList.add("is-pick");
|
||
box.tabIndex = 0;
|
||
box.setAttribute("role", "button");
|
||
const fire = (ev: Event): void => {
|
||
ev.stopPropagation();
|
||
h.onVar?.(node);
|
||
};
|
||
box.addEventListener("click", fire);
|
||
box.addEventListener("keydown", (ev) => (ev.key === "Enter" || ev.key === " ") && fire(ev));
|
||
}
|
||
return box;
|
||
}
|
||
|
||
function block(node: EqNode, h: EqHandlers): HTMLElement {
|
||
const at = (h.blocks ?? []).push(node);
|
||
const value = eqText(node.값);
|
||
const chip = span(
|
||
"ui-eq__block",
|
||
span("ui-eq__badge", String.fromCodePoint(0x245f + at)), // ① ② …
|
||
span("ui-eq__block-name", node.이름 || node.열쇠 || ""),
|
||
value ? span("ui-eq__block-val", value) : "",
|
||
);
|
||
chip.dataset.eqBlock = node.열쇠 ?? "";
|
||
chip.title = `${node.열쇠 ?? ""} ${node.이름 ?? ""}`.trim();
|
||
if (h.onBlock) {
|
||
chip.classList.add("is-pick");
|
||
chip.tabIndex = 0;
|
||
chip.setAttribute("role", "button");
|
||
const fire = (ev: Event): void => {
|
||
ev.stopPropagation();
|
||
h.onBlock?.(node);
|
||
};
|
||
chip.addEventListener("click", fire);
|
||
chip.addEventListener("keydown", (ev) => (ev.key === "Enter" || ev.key === " ") && fire(ev));
|
||
}
|
||
return chip;
|
||
}
|
||
|
||
/** 수식 나무 → 인라인 요소 */
|
||
export function renderEq(node: EqNode | undefined, h: EqHandlers = {}): HTMLElement {
|
||
if (!node) return span("ui-eq__expr");
|
||
const sub = (child: EqNode | string | undefined): HTMLElement =>
|
||
typeof child === "string" ? span("ui-eq__text", child) : renderEq(child, h);
|
||
switch (node.꼴) {
|
||
case "수":
|
||
return span("ui-eq__num", node.글 ?? "");
|
||
case "글":
|
||
return span("ui-eq__str", node.따옴 ? `'${node.글 ?? ""}'` : (node.글 ?? ""));
|
||
case "변수":
|
||
return variable(node, h);
|
||
case "블록":
|
||
return block(node, h);
|
||
case "연산": {
|
||
const sign = SHOW[node.기호 ?? ""] ?? node.기호 ?? "";
|
||
return span("ui-eq__expr", sub(node.좌), span("ui-eq__op", sign), sub(node.우));
|
||
}
|
||
case "분수": {
|
||
const frac = span(
|
||
"ui-eq__frac",
|
||
span("ui-eq__top", sub(node.위)),
|
||
span("ui-eq__bottom", sub(node.아래)),
|
||
);
|
||
if (node.사슬) frac.dataset.chain = "1";
|
||
return frac;
|
||
}
|
||
case "괄호":
|
||
return span("ui-eq__paren", span("ui-eq__inner", sub(node.안)));
|
||
case "퍼센트":
|
||
return span("ui-eq__expr", sub(node.안), span("ui-eq__op", "%"));
|
||
case "음":
|
||
return span("ui-eq__expr", span("ui-eq__op", "−"), sub(node.안));
|
||
case "조건": {
|
||
const pick = node.걸림 ?? 0;
|
||
const chip = span("ui-eq__cond", span("ui-eq__cond-key", "만약"), sub(node.조건));
|
||
chip.title = `참 ${plainEq(node.참)} · 거짓 ${plainEq(node.거짓)}`;
|
||
if (pick === 1 || pick === 2)
|
||
return span("ui-eq__expr", chip, sub(pick === 1 ? node.참 : node.거짓));
|
||
return span(
|
||
"ui-eq__expr",
|
||
chip,
|
||
span("ui-eq__op", "참"),
|
||
sub(node.참),
|
||
span("ui-eq__op", "거짓"),
|
||
sub(node.거짓),
|
||
);
|
||
}
|
||
case "함수": {
|
||
const args = span("ui-eq__expr");
|
||
(node.인자 ?? []).forEach((a, i) =>
|
||
args.append(...(i ? [span("ui-eq__op", ","), sub(a)] : [sub(a)])),
|
||
);
|
||
return span("ui-eq__expr", span("ui-eq__fn", node.이름 ?? ""), span("ui-eq__paren", args));
|
||
}
|
||
default:
|
||
return span("ui-eq__expr", node.글 ?? "");
|
||
}
|
||
}
|