Files
Aislo/A00_Common/formula_doc_lines.ts
T
eomsangdonandClaude Opus 5.5 f5545dd62b feat(803-2a): 수식 탭 세 영역 · 하나의 수식 영역 · 합계 상자 · 설명 글 지움
- 세 영역 가로 — 기호 식(줄바꿈으로 이어진 하나의 수식 영역) | 값 식(같은 높이 줄) | 기호 정의
- 맨 아래 합계 상자 = 단가산출 상세 탭 부품(노무비 · 재료비 · 경비 · 계) · 옵션 아래 계 줄 · 설명 글 · 표지 안내 줄 걷음(오류 = 그 기호 빨강)
- 기호 식 줄 누름 = 그 자리 편집기(치는 대로 조판 — / 분수 · _ 아래첨자 · ^ 위첨자 · 괄호 · 화살표 구조 이동 · Enter 새 줄 · Esc 나옴 · 한글 조합) · 편집 중만 영역 위 수식 도구 줄
- 금액 줄 = 영역 안 식 줄 「노무비 형틀목공 = w × Q」(머리 첫 말 = 비목) · 「+ 식 줄」 「+ 금액 줄」 「+ 묶음」 · 비목 고르기 칸 걷음 · 묶음 = 도구 줄 [묶음]
- 저장 전 새 로직 = 세 영역 빈 화면 · 끊은 단가산출 블록도 같은 편집기
- 새 라이브러리 없음 · 서버 길 · 계산기 · 문서 꼴 그대로

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

357 lines
14 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_lines.ts
* 수식 문서 줄(PLAN 802-3 · 803-2a · 요구 660~667) — 왼쪽 「기호 식」 = 줄바꿈으로 이어진 하나의 수식 영역 · 가운데 「값 식」 같은 높이
* 식 줄: `Q = 3600 × q … ÷ Cm` · 값 식 `= 값 식 = 결과 단위`
* 금액 줄: `노무비 형틀목공 = w × Q`(비목 · 이름 = 단가 × 수량) · 값 식 `= 값 × 값 = 금액`(묶음 = 노 · 재 · 경 세 줄)
* 묶음: 하나 = 머리 없음 · 둘 이상 = 「가. 이름」 머리 + 「소계」 · 맨 끝 합은 화면 맨 아래 합계 상자
* 왼쪽 줄 누름 = 그 자리 편집기(`formula_doc_math.ts` · 부른 쪽 `mount`) · 영역 맨 아래 빈 줄 = 새 줄
* 값 식 숫자 누름 = 그 자리 값 · 기호 값 누름 = 정의 줄 · 그 밖 = 그 줄 편집
* 고친 것은 부른 쪽(`formula_doc_screen.ts`) 훅으로 — 여기선 문서를 안 고침
* ========================================================================== */
import { eqText } from "@ui/ui_template_equation";
import { editNumber, serializeEq, tidyEq } from "@ui/ui_template_equation_edit";
import { el } from "@ui/ui_template_elements";
import type { DocVar, FormulaDoc, MoneyLine, Node } from "../common_util/common_util_formula_doc";
import type { DocAnswer } from "../common_util/common_util_formula_doc_calc";
import { moneyScope } from "../common_util/common_util_formula_doc_view";
import {
lineMap,
litsOf,
offLine,
renderNode,
symNode,
takenOf,
tryParse,
valueNode,
type Values,
} from "./formula_doc_render";
import { ft } from "./formula_doc_text";
export const COSTS = ["노무비", "재료비", "경비"] as const;
const ORDER = "가나다라마바사아자차카타파하";
/** 줄 자리 — 식 줄 · 금액 줄 차례 */
export interface RowRef {
kind: "line" | "money";
i: number;
}
export interface LinesHooks {
admin: boolean;
/** 선택으로 꺼진 금액 줄도 보임(고침 모드) */
showOff?: boolean;
doc: FormulaDoc;
answer: DocAnswer;
vars: Map<string, DocVar>;
/** 기호 · 금액 줄 이름 → 표지 글 */
marks: Map<string, string>;
/** 편집 중인 줄 */
editing: RowRef | null;
/** 편집기를 그 줄에 엶(글 = 선형 꼴) */
mount: (host: HTMLElement, ref: RowRef, text: string) => void;
/** 줄 누름 — 편집 시작(`end` = 영역 맨 아래 새 줄) */
onEditAt: (ref: RowRef | "end", at?: { x: number; y: number }) => void;
onLit: (target: "line" | "price" | "qty", i: number, at: number, value: number) => void;
onPickVar: (id: string, number?: HTMLElement) => void;
onGroupName: (old: string, now: string) => void;
onRedraw: () => void;
}
const span = (cls: string, text?: string): HTMLSpanElement =>
el("span", { className: cls, ...(text !== undefined ? { text } : {}) });
const memo = new WeakMap<DocAnswer, Values>();
/** 기호 값 + 금액 줄 기호(그 줄 금액 — 뒤 금액 줄이 부름) */
function vals(h: LinesHooks): Values {
let got = memo.get(h.answer);
if (!got) {
got = { ...(h.answer.값 ?? {}) };
const outs = (h.answer.줄 ?? []) as { 금액?: number | null }[];
(h.doc.금액 ?? []).forEach((l, i) => {
if (l.기호 && typeof outs[i]?.금액 === "number") got![String(l.기호)] = outs[i].금액;
});
memo.set(h.answer, got);
}
return got;
}
/** 식 글 → 그림(못 풀면 글 그대로 빨강) · scope = 금액 줄이 부르는 비목 합(앞 줄 누계) */
function expr(
text: unknown,
h: LinesHooks,
side: "sym" | "val",
inline = false,
cost?: string,
scope?: Values,
) {
const n = tryParse(text);
if (!n) {
const raw = span("fdoc__raw", String(text ?? ""));
if (String(text ?? "").trim()) raw.dataset.bad = "1";
return { node: raw, tree: null as Node | null };
}
const lits = side === "val" ? litsOf(n) : undefined;
return {
node: renderNode(n, {
side,
vals: scope ? { ...vals(h), ...scope } : vals(h),
vars: h.vars,
inline,
cost,
lits,
}),
tree: n,
};
}
const filled = (t: unknown): boolean => t !== null && t !== undefined && String(t).trim() !== "";
/** 금액 줄 단가 × 수량 한 식 글 */
export const moneyBody = (l: MoneyLine): string =>
filled(l.수량) ? `(${String(l.단가 ?? "")}) × (${String(l.수량)})` : String(l.단가 ?? "");
/** 왼쪽 칸 — 누름 = 편집 · 편집 중이면 편집기 */
function areaCell(h: LinesHooks, ref: RowRef, edit: HTMLElement, text: () => string): HTMLElement {
const left = el("div", {
className: "fdoc__cell fdoc__sym",
attrs: { "data-row": `${ref.kind}${ref.i}` },
children: [edit],
});
if (!h.admin) return left;
if (h.editing && h.editing.kind === ref.kind && h.editing.i === ref.i) {
left.classList.add("is-editing");
h.mount(edit, ref, text());
} else
left.addEventListener("mousedown", (ev) => {
ev.preventDefault();
h.onEditAt(ref, { x: ev.clientX, y: ev.clientY });
});
return left;
}
/** 값 식 누름 — 수 = 그 자리 값 · 기호 값 = 정의 줄 · 그 밖 = 그 줄 편집 */
function onValueClick(
ev: MouseEvent,
host: HTMLElement,
h: LinesHooks,
target: "line" | "price" | "qty",
i: number,
ref: RowRef,
): void {
if (!h.admin || host.dataset.editing) return;
const t = ev.target as HTMLElement;
const lit = t.closest<HTMLElement>("[data-lit]");
const atom = t.closest<HTMLElement>("[data-var]");
if (lit && host.contains(lit)) {
ev.stopPropagation();
editNumber(lit, (text) => {
const v = Number(text.replace(/,/g, ""));
if (Number.isFinite(v)) h.onLit(target, i, Number(lit.dataset.lit), v);
else h.onRedraw();
});
} else if (atom && host.contains(atom)) {
ev.stopPropagation();
h.onPickVar(atom.dataset.var!, atom.querySelector<HTMLElement>(".ui-eq__num") ?? undefined);
} else h.onEditAt(ref);
}
function lineRow(i: number, h: LinesHooks): HTMLElement[] {
const line = (h.doc.식 ?? [])[i];
const ref: RowRef = { kind: "line", i };
const head = String(line.기호 ?? "");
const edit = span("fdoc__edit");
if (head) {
const a = span("ui-eq__var fdoc__atom fdoc__head");
a.dataset.tok = head;
a.append(symNode(head));
edit.append(a, span("ui-eq__op", "="));
}
edit.append(expr(line.식, h, "sym").node);
const bad = h.marks.get(head);
if (bad) {
edit.dataset.bad = "1";
edit.title = bad;
}
const left = areaCell(h, ref, edit, () => tidyEq(serializeEq(edit)));
const right = el("div", {
className: "fdoc__cell fdoc__valcell",
attrs: { "data-line": String(i) },
});
const vexpr = span("fdoc__edit");
vexpr.append(expr(line.식, h, "val").node);
const got = vals(h)[head];
right.append(span("ui-eq__op", "="), vexpr);
// 수 하나 · 기호 하나(탄 갈래) 줄 = 값 한 번만(「= 1 = 1」 없음)
let eff = tryParse(line.식);
for (let pick = eff ? takenOf(eff, vals(h)) : 0; eff && pick; pick = takenOf(eff, vals(h)))
eff = (eff as ["call", string, Node[]])[2][pick];
const single = !!eff && (eff[0] === "num" || (eff[0] === "var" && vals(h)[eff[1]] === got));
if (single) {
/* 값 식이 곧 결과 */
} else if (got !== null && got !== undefined && typeof got !== "object") {
right.append(span("ui-eq__op", "="), valueNode(got, unitOf(head, h)));
} else right.append(span("ui-eq__op", "="), span("fdoc__dash", "—"));
if (h.admin) vexpr.addEventListener("click", (ev) => onValueClick(ev, vexpr, h, "line", i, ref));
return [left, right];
}
const unitOf = (id: string, h: LinesHooks): string =>
h.vars.get(id)?.단위 || (h.doc.결과 === id ? (h.doc.결과단위 ?? "") : "");
function moneyRow(i: number, h: LinesHooks): HTMLElement[] {
const line = (h.doc.금액 ?? [])[i];
const ref: RowRef = { kind: "money", i };
const got = (h.answer.줄 ?? [])[i] as
{ 비목?: Record<string, number>; 금액?: number | null; 까닭?: string } | undefined;
const kind = line.비목 ?? "노무비";
const name = line.이름 ?? "";
const body = expr(moneyBody(line), h, "sym", true).node;
const edit = span("fdoc__edit fdoc__money");
edit.append(span("fdoc__kindtag", kind), span("fdoc__name", name), span("ui-eq__op", "="), body);
const bad = h.marks.get(name);
if (bad) {
edit.dataset.bad = "1";
edit.title = bad;
}
const left = areaCell(h, ref, edit, () => tidyEq(`${kind} ${name} = ${serializeEq(body)}`));
const right = el("div", { className: "fdoc__cell fdoc__valcell fdoc__money" });
const costs = kind === "묶음" ? [...COSTS] : [null];
const scope = moneyScope(h.answer.줄, i);
const hasQty = filled(line.수량);
for (const cost of costs) {
const row = span("fdoc__mrow");
if (cost) row.append(span("fdoc__cost", cost), span("ui-eq__op", ":"));
const pv = span("fdoc__edit");
pv.append(expr(line.단가, h, "val", true, cost ?? undefined, scope).node);
row.append(pv);
if (hasQty) {
const qv = span("fdoc__edit");
qv.append(expr(line.수량, h, "val", true, undefined, scope).node);
row.append(span("ui-eq__op", "×"), qv);
if (h.admin) qv.addEventListener("click", (ev) => onValueClick(ev, qv, h, "qty", i, ref));
}
if (h.admin) pv.addEventListener("click", (ev) => onValueClick(ev, pv, h, "price", i, ref));
const amount = cost ? got?.비목?.[cost] : got?.금액;
row.append(span("ui-eq__op", "="));
if (typeof amount === "number") row.append(span("ui-eq__num fdoc__amount", eqText(amount)));
else {
const dash = span("fdoc__dash", "—");
if (got?.까닭) dash.title = got.까닭;
row.append(dash);
}
right.append(row);
}
return [left, right];
}
/** 이름 그 자리 고침(글만) */
function editName(node: HTMLElement, before: string, done: (text: string) => void): void {
node.textContent = before;
node.contentEditable = "true";
node.dataset.editing = "1";
let over = false;
const finish = (keep: boolean): void => {
if (over) return;
over = true;
node.contentEditable = "false";
const text = (node.textContent ?? "").trim();
if (keep && text !== before) done(text);
else node.textContent = before || ft("Unnamed");
delete node.dataset.editing;
};
node.addEventListener("keydown", (ev) => {
if (ev.key === "Enter" || ev.key === "Escape") {
ev.preventDefault();
finish(ev.key === "Enter");
}
});
node.addEventListener("blur", () => finish(true), { once: true });
queueMicrotask(() => {
node.focus();
window.getSelection()?.selectAllChildren(node);
});
}
/** 소계 · 계 줄(왼쪽 이름 · 가운데 노 · 재 · 경 · 계) */
export function sumRow(label: string, b: Record<string, unknown> | null): HTMLElement[] {
const left = el("div", {
className: "fdoc__cell fdoc__sym fdoc__total",
children: [span("fdoc__name", label)],
});
const right = el("div", { className: "fdoc__cell fdoc__total" });
for (const k of [...COSTS, "계"]) {
const v = b?.[k];
right.append(
span("fdoc__tk", k),
typeof v === "number" ? span("ui-eq__num fdoc__amount", eqText(v)) : span("fdoc__dash", "—"),
);
}
return [left, right];
}
/** 선택으로 꺼진 금액 줄 차례 */
export function offLines(doc: FormulaDoc, answer: DocAnswer): Set<number> {
const h = { doc, answer } as LinesHooks;
const steps = lineMap(doc);
return new Set((doc.금액 ?? []).flatMap((l, i) => (offLine(l, vals(h), steps) ? [i] : [])));
}
/** 왼쪽 수식 영역 · 가운데 값 식 — 격자 칸 차례(두 칸씩) · 보이는 줄 차례(위아래 옮김) */
export function buildLines(h: LinesHooks): { cells: HTMLElement[]; order: RowRef[] } {
const out: HTMLElement[] = [];
const order: RowRef[] = [];
const add = (cells: HTMLElement[]): void => void out.push(...cells);
(h.doc.식 ?? []).forEach((_, i) => {
add(lineRow(i, h));
order.push({ kind: "line", i });
});
const lines = h.doc.금액 ?? [];
const groups: string[] = [];
lines.forEach((l) => groups.includes(l.묶음 ?? "") || groups.push(l.묶음 ?? ""));
const off = offLines(h.doc, h.answer);
const seen = (i: number): boolean => !!h.showOff || !off.has(i);
const many = groups.length > 1;
const bundles = (h.answer.묶음 ?? []) as Record<string, unknown>[];
const done = h.answer.셈됨;
groups.forEach((g, gi) => {
if (!lines.some((l, i) => (l.묶음 ?? "") === g && seen(i))) return; // 다 꺼진 묶음
if (many) {
const head = span("fdoc__group", `${ORDER[gi] ?? gi + 1}. `);
const label = span("fdoc__name", g || ft("Unnamed"));
head.append(label);
if (h.admin)
label.addEventListener("click", () => editName(label, g, (t) => h.onGroupName(g, t)));
add([
el("div", { className: "fdoc__cell fdoc__sym fdoc__grouphead", children: [head] }),
el("div"),
]);
}
lines.forEach((l, i) => {
if ((l.묶음 ?? "") !== g || !seen(i)) return;
add(moneyRow(i, h));
order.push({ kind: "money", i });
});
if (many) add(sumRow(ft("Subtotal"), done ? (bundles[gi] ?? null) : null));
});
if (h.admin || !out.length) {
// 영역 맨 아래 빈 줄 — 누르면 새 줄(빈 문서 = 빈 영역)
const blank = el("div", { className: "fdoc__cell fdoc__sym fdoc__blank" });
if (!out.length) blank.classList.add("is-alone");
if (h.admin)
blank.addEventListener("mousedown", (ev) => {
ev.preventDefault();
h.onEditAt("end");
});
add([blank, el("div")]);
}
// 칸 테 — 기호 식 · 값 식 영역마다 맨 위 · 맨 아래
for (const k of [0, 1]) {
const col = out.filter((_, n) => n % 2 === k);
col.forEach((c) => c.classList.add(k ? "fdoc__val" : "fdoc__area"));
col[0]?.classList.add("is-top");
col[col.length - 1]?.classList.add("is-bottom");
}
return { cells: out, order };
}