- 세 영역 가로 — 기호 식(줄바꿈으로 이어진 하나의 수식 영역) | 값 식(같은 높이 줄) | 기호 정의 - 맨 아래 합계 상자 = 단가산출 상세 탭 부품(노무비 · 재료비 · 경비 · 계) · 옵션 아래 계 줄 · 설명 글 · 표지 안내 줄 걷음(오류 = 그 기호 빨강) - 기호 식 줄 누름 = 그 자리 편집기(치는 대로 조판 — / 분수 · _ 아래첨자 · ^ 위첨자 · 괄호 · 화살표 구조 이동 · Enter 새 줄 · Esc 나옴 · 한글 조합) · 편집 중만 영역 위 수식 도구 줄 - 금액 줄 = 영역 안 식 줄 「노무비 형틀목공 = w × Q」(머리 첫 말 = 비목) · 「+ 식 줄」 「+ 금액 줄」 「+ 묶음」 · 비목 고르기 칸 걷음 · 묶음 = 도구 줄 [묶음] - 저장 전 새 로직 = 세 영역 빈 화면 · 끊은 단가산출 블록도 같은 편집기 - 새 라이브러리 없음 · 서버 길 · 계산기 · 문서 꼴 그대로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
357 lines
14 KiB
TypeScript
357 lines
14 KiB
TypeScript
/* =============================================================================
|
||
* 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 };
|
||
}
|