/* ============================================================================= * 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; /** 기호 · 금액 줄 이름 → 표지 글 */ marks: Map; /** 편집 중인 줄 */ 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(); /** 기호 값 + 금액 줄 기호(그 줄 금액 — 뒤 금액 줄이 부름) */ 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("[data-lit]"); const atom = t.closest("[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(".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; 금액?: 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 | 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 { 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[]; 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 }; }