Files
Aislo/ui_template/ui_template_equation.ts
T
eomsangdonandClaude Sonnet 5.5 47f260e012 feat(801-3): 수식 탭 소계 · 합계 묶음 다시 — 돈 묶음 하나 = 머리 없음 · 계 / 둘 이상 = 소계 / 단가산출 둘 이상 = 합계 · 안쪽 말 0
- 묶음 한 곳 master_equation_group.bundles: 부른 · 더한 단가산출 하나씩 + 직접 줄(인력 · 재료 · 기계 · 덧줄) 한 묶음 · 돈 없는 줄 · 뺀 줄 = 묶음 아님 · 합계 밖 줄 = 밖 글
- M01 subtotals · M02 M02_Sangeun_Equation_Full 같은 꼴 · 끝수 셈 그대로 · 금액 전후 같음
- 부품 ui_template_equation_sum: 자기 자원 · 덧줄 글 0 · 묶음 하나 = 머리 없음 · 계 · 둘 이상 = 가. 머리 · 소 계 · 단가산출 둘 이상 = 합계
- 옛 시험 기대 고침: test_799_1(갈래 자원 · 덧줄 → 직접 한 묶음) · test_796_2c(줄마다 한 행 → 묶음 · 낱줄 · 밖) — 묶음 꼴이 바뀜
- 새 test_801_3_groups 5

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 17:44:49 +09:00

345 lines
12 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.
/* =============================================================================
* 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 · 801-3) — 갈래 블록(부른 · 더한 단가산출) | 직접(인력 · 재료 · 기계 · 덧줄 한 묶음) · M02 SK 는 상태 */
export interface EqSumRow {
갈래: string;
이름: string;
노무비: unknown;
재료비: unknown;
경비: unknown;
계: unknown;
/** M02 SK — 바탕 | 덮음 | 더함 | 뺌 (M01 은 없음) */
상태?: string;
수량?: unknown;
/** 덮음 갈래(품 · 단가 · 상수 · 표값 · 식) */
덮개?: string[];
/** 덮음 · 뺌 — 고치기 전 금액 */
원래?: unknown;
까닭?: string;
/** 직접 묶음 속 더한 · 덮은 줄(M02 SK) */
낱줄?: { 이름: string; 상태: string; 계: unknown; 원래?: 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 · 801-3 — 묶음마다 노 · 재 · 경 소계 + 끝수(합계 − 묶음 합) · 합계 밖 줄 · 결과 로직은 없음 */
소계?: {
줄: EqSumRow[];
끝수: Record<string, unknown>;
끝수까닭?: string;
밖?: { 이름: string; 밖금액: unknown }[];
};
/** 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.글 ?? "");
}
}