Files
Aislo/A00_Common/line_stack_out.ts
T
eomsangdonandClaude Opus 5.5 af576e5a7a feat(804-5): 줄 쌓기 왼쪽 손질 — 자원 이름 단추 · 항목 띠 · 끌어 옮기기 · X 지움 되돌리기 · 율 번지 읽는 이름
- 인력 · 기계 · 재료 · 단가산출 줄 자원 이름 = 단추(이름 · 규격) → 찾아 넣기 창 → 바꿈 · [찾아 바꾸기] 걷음
- 항목 머리 = 띠(바탕 · 왼쪽 굵은 띠 · 번호) · 줄 = 들여쓰기 + 왼쪽 세로 선 · 가나다 한 단 더 · 접기
- 순서 = 손잡이 끌기(줄 = 항목 안 · 사이 · 항목 끌기 · 자리 선 표시) · [↑][↓] 걷음
- 지움 = X 아이콘 · 확인 없이 지우고 알림 눌러 되돌리기(그 뒤 고침 있으면 못 함)
- 산출근거 율 줄 「번지.복사N.노무비」 → 그 조각 이름 · 값 식 = 번지 값 · 합계 덧셈 항 넷 넘으면 항목 소계끼리
- 알림 부품: 누름 동작 알림은 같은 문구 버림 안 함(두 번째 지움 되돌리기 안 뜨던 흠)
- 글 · 근거 카드 높이 늘던 흠 고침 · 시험 test_804_4 끌어옮김 · 번지 이름 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:29:31 +09:00

405 lines
15 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.
/* =============================================================================
* line_stack_out.ts
* 줄 쌓기 산출근거 출력(PLAN 804-2 · 요구 670-1 · 677) — 경쟁사 「단가산출」 시트 꼴 표(산출근거 · 합계 · 노무비 · 재료비 · 경비)
* 공식 줄 = 「기호 = 기호식 = 값식 = 결과 단위」(값만인 줄은 넷까지 한 줄) · 금액 줄 = 「이름 : 단가 × 수량 단위 = 금액」
* 기계 · 단가산출 줄 = 비목마다 「노무비 : 59,020 × … = 금액」 — 단가 수 누름(▸) = 그 문서(기계 시간당 경비 등)를
* 같은 계산기로 셈해 같은 꼴로 펼침(손료 식 · 연료 · 운전사 · 노 · 재 · 경 · 계) · 접으면 한 줄 · 출력엔 펼친 것만
* 값 = 계산기 셈 답 그대로(`calcDoc` · 줄 `answer.줄` · 합 `노무비` …) — 여기서 다시 셈 안 함 · 조판 = KaTeX(`line_stack_tex.ts`)
* ========================================================================== */
import { createButton } from "@ui/ui_template_button";
import { eqText } from "@ui/ui_template_equation";
import { el } from "@ui/ui_template_elements";
import { tableRow, tableShell, type Column } from "@ui/ui_template_table";
import { COSTS, varId, type FormulaDoc } from "../common_util/common_util_formula_doc";
import { calcDoc, isNum, type DocAnswer } from "../common_util/common_util_formula_doc_calc";
import type { DocCache } from "../common_util/common_util_formula_doc_cache";
import {
lineMap,
moneyScope,
offLine,
type Values,
} from "../common_util/common_util_formula_doc_view";
import { reach } from "./formula_doc_render";
import {
kindOf,
nodeOf,
priceSym,
stackOf,
subInputs,
type StackDoc,
} from "../common_util/common_util_line_stack";
import { placeNames } from "../common_util/common_util_line_stack_copy";
import { stackLevels } from "../common_util/common_util_line_stack_pick";
import { katexNode, symTex, tex, valTex } from "./line_stack_tex";
import { lt } from "./line_stack_text";
type Cols = Partial<Record<"sum" | (typeof COSTS)[number], number>>;
interface Row {
h: (string | HTMLElement)[];
cols?: Cols;
cls?: string;
won?: boolean;
depth: number;
/** 들여쓰기 수준(804-3) — 머리 0 · 항목 1 · 가나다 2 · 줄 = 항목 + 1 · 펼친 블록 = 그 줄 + 1 */
lv: number;
/** 왼쪽 짝 자리(679) — 항목 `k` · 줄 `k.j`(펼친 블록 = 그 줄) */
loc?: string;
}
export interface OutHooks {
/** 줄 누름 — 왼쪽 짝 자리(679) */
pair?: (loc: string) => void;
cache: DocCache;
/** 펼친 자리(`부모/기호`) */
open: Set<string>;
toggle: (at: string) => void;
/** 캐시에 없는 연결 — 뒤에서 서버에 물음 */
miss: () => void;
}
const COST_SHOW: Record<string, string> = { 노무비: "노무비", 재료비: "재료비", 경비: "경 비" };
const won = (n: number): string => eqText(Math.round(n));
const one = (n: number): string =>
n.toLocaleString("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
const MAX_DEPTH = 3;
const LONG_SUM = 4; // 합계 덧셈 항이 이보다 많으면 항목 소계끼리(경쟁사 「노무비 : 2,523.3 + 919.5 = 3,442」)
/** 식 글 → 조판(못 풀면 글) */
function exprNode(
text: unknown,
side: "sym" | "val",
vals: Values,
inline = false,
cost?: string,
place?: Record<string, string>,
) {
const n = nodeOf(text);
return n
? katexNode(tex(n, { side, vals, inline, cost, place }))
: el("span", { text: String(text ?? "") });
}
const hasVar = (text: unknown): boolean => {
const n = nodeOf(text);
return !!n && JSON.stringify(n).includes('["var"');
};
function rows(
doc: StackDoc,
a: DocAnswer,
h: OutHooks,
depth: number,
at: string,
base = 0,
): Row[] {
const out: Row[] = [];
let lv = base + 1; // 지금 쌓는 줄 수준
let loc: string | undefined; // 지금 쌓는 왼쪽 자리(맨 위 문서만)
const vals = (a.값 ?? {}) as Values;
const vars = new Map((doc.변수 ?? []).map((v) => [varId(v), v]));
const lines = new Map((doc.식 ?? []).map((l) => [String(l.기호 ?? ""), l.식]));
// 펼친 문서 줄 = 금액 칸 없이(부른 쪽 칸과 섞이지 않게)
const push = (r: Omit<Row, "depth" | "lv">): void =>
void out.push({ ...r, depth, lv, loc, ...(depth ? { cols: undefined } : {}) });
const shown = new Set(reach(doc, vals)); // 지금 탄 갈래로 닿는 기호만
const steps = lineMap(doc);
const place = placeNames(doc, stackOf(doc)); // 번지 읽는 이름(804-5)
const unit = (id: string): string => vars.get(id)?.단위 ?? "";
const subOf = (id: string): FormulaDoc | null => {
const k = vars.get(id)?.연결;
if (k?.갈래 !== "단가산출" || !k.키 || k.키.includes("{")) return null;
try {
return h.cache.links.doc(k.키) ?? null;
} catch {
if (h.cache.takeMissing()) h.miss();
return null;
}
};
/** ▸ 단추 — 펼친 자리면 그 문서 줄을 뒤에 끼움 */
const opener = (id: string, label: string): HTMLElement => {
const path = `${at}/${id}`;
const b = createButton({
label: `${label} ${h.open.has(path) ? "▾" : "▸"}`,
variant: "link",
size: 20,
onClick: () => h.toggle(path),
});
b.dataset.expand = path;
return b;
};
const expand = (id: string): void => {
const path = `${at}/${id}`;
const sub = subOf(id);
if (!h.open.has(path) || !sub || depth >= MAX_DEPTH) return;
const got = calcDoc(sub, h.cache.links, subInputs(vars.get(id)?.연결, vals));
if (h.cache.takeMissing()) h.miss();
out.push({
depth: depth + 1,
lv: lv + 1,
loc,
h: [`${sub.이름 ?? ""}${sub.결과단위 ? ` [${sub.결과단위}]` : ""}`],
cls: "subhead",
});
out.push(...rows(sub, got, h, depth + 1, path, lv + 1).map((r) => ({ ...r, loc })));
};
/** 공식 묶음 — 값만인 줄은 넷까지 한 줄 */
const block = (ids: string[]): void => {
let pack: HTMLElement[] = [];
const flush = (): void => {
if (pack.length)
push({ h: pack.flatMap((x, k) => (k ? [el("span", { className: "lstk-gap" }), x] : [x])) });
pack = [];
};
for (const id of ids.filter((x) => shown.has(x))) {
const v = vals[id];
const t = lines.get(id);
const head = katexNode(symTex(id));
if (t !== undefined && hasVar(t)) {
flush();
const res = isNum(v)
? katexNode(`${valTex(v)}\\ ${unit(id) ? `\\text{${unit(id)}}` : ""}`)
: missing(a, id);
// 기호식 = 값식 = 결과 — 같은 꼴이 이어지면 한 번만(만약이 수 하나를 탄 줄)
const n = nodeOf(t)!;
const sym = tex(n, { side: "sym", vals });
const val = tex(n, { side: "val", vals });
const same = isNum(v) && val === valTex(v);
push({
h: [
head,
...(sym === val ? [] : [" = ", katexNode(sym)]),
...(same ? [] : [" = ", katexNode(val)]),
" = ",
res,
],
});
continue;
}
const sub = subOf(id);
if (sub || (v && typeof v === "object")) {
flush();
const label =
v && typeof v === "object"
? COSTS.map((c) => `${COST_SHOW[c]} ${eqText((v as Values)[c])}`).join(" · ")
: isNum(v)
? `${eqText(v)} ${unit(id) || sub?.결과단위 || ""}`
: "—";
push({
h: [head, " = ", sub ? opener(id, label) : label, sub ? ` ${sub.이름 ?? ""}` : ""],
});
expand(id);
continue;
}
const expr = t !== undefined && !isNum(Number(t)) ? [" = ", exprNode(t, "sym", vals)] : [];
const cell = el("span", {
className: "lstk-pack",
children: [
head,
...expr,
" = ",
isNum(v) ? `${eqText(v)} ${unit(id)}` : typeof v === "string" ? v : missing(a, id),
],
});
pack.push(cell);
if (pack.length >= 4) flush();
}
flush();
};
const money = doc.금액 ?? [];
const lineRows = (i: number): { sub: boolean; cols: Cols } => {
const line = money[i];
const r = (a.줄?.[i] ?? {}) as {
비목?: Record<string, number>;
금액?: number | null;
까닭?: string;
단가?: unknown;
};
const scope: Values = { ...vals, ...moneyScope(a.줄, i) };
const at: Record<string, Values> = {}; // 앞 줄 번지 합(계산기와 같음 — 번지 값을 값 식에 · 804-5)
money.slice(0, i).forEach((m, k) => {
const got = a.줄?.[k] as { 금액?: unknown; 비목?: Values } | undefined;
if (m.기호) scope[m.기호] = got?.금액; // 앞 금액 줄 기호 = 그 줄 금액
if (m.번지)
for (const c of COSTS) {
const box = (at[String(m.번지)] ??= { 노무비: 0, 재료비: 0, 경비: 0 });
if (isNum(got?.비목?.[c]))
box[c] = Math.round(((box[c] as number) + (got!.비목![c] as number)) * 1e6) / 1e6;
}
});
if (Object.keys(at).length) scope.번지 = at;
const name = [line.이름, (line as { 규격?: string }).규격].filter(Boolean).join(" ");
const qty =
line.수량 !== undefined && line.수량 !== null && line.수량 !== "" ? line.수량 : null;
const qtyNode = (cost?: string): (string | HTMLElement)[] =>
qty === null
? []
: [" × ", exprNode(qty, "val", scope, true, cost), line.단위 ? ` ${line.단위}` : ""];
if (r.까닭 && r.금액 === null) {
push({ h: [`${name} : — `, el("span", { className: "lstk-miss", text: r.까닭 })] });
return { sub: false, cols: {} };
}
if (line.비목 === "묶음") {
const s = priceSym(line);
const bundle = (r.단가 ?? {}) as Values;
const cols: Cols = {};
push({ h: [name || (s ? (subOf(s)?.이름 ?? s) : "")], cls: "lname" });
for (const c of COSTS) {
const part = r.비목?.[c];
if (!isNum(part) || (!part && !bundle[c])) continue;
cols[c] = part;
push({
h: [
`${COST_SHOW[c]} : `,
s ? opener(s, eqText(bundle[c])) : eqText(bundle[c]),
...qtyNode(c),
` = ${one(part)}`,
],
});
}
if (s) expand(s);
return { sub: true, cols };
}
const amt = r.금액 ?? 0;
const price =
hasVar(line.단가) && kindOf(doc, i) === "율"
? [
exprNode(line.단가, "sym", scope, true, undefined, place),
" = ",
exprNode(line.단가, "val", scope, true),
]
: [exprNode(line.단가, "val", scope, true)];
const c = String(line.비목);
push({
h: [`${name} : `, ...price, ...qtyNode(), ` = ${one(amt)}`],
cols: { sum: amt, [c]: amt },
});
return { sub: false, cols: { [c]: amt } };
};
const items = stackOf(doc);
const terms: Record<string, number[]> = { 노무비: [], 재료비: [], 경비: [] };
const perItem: Record<string, number[]> = { 노무비: [], 재료비: [], 경비: [] }; // 덧셈이 길면 항목 소계끼리(804-5)
let n = 0;
let sub = 0;
const levels = stackLevels(items);
for (const [k, it] of items.entries()) {
lv = base + levels[k].item;
loc = depth ? undefined : String(k);
if (it.제목.trim()) {
const label = it.가나다 ? `${"가나다라마바사아자차카타파하"[sub++] ?? "?"} . ` : `${++n}. `;
if (!it.가나다) sub = 0;
push({ h: [label + it.제목], cls: it.가나다 ? "item sub" : "item" });
}
const sum: Record<string, number> = { 노무비: 0, 재료비: 0, 경비: 0 };
const own: Record<string, number[]> = { 노무비: [], 재료비: [], 경비: [] };
let hasSub = false;
lv = base + levels[k].line;
for (const [j, l] of it.줄.entries()) {
loc = depth ? undefined : `${k}.${j}`;
if (l.꼴 === "글") push({ h: [l.글] });
else if (l.꼴 === "공식") block(l.기호);
else {
if (offLine(money[l.i], vals, steps)) continue; // 선택으로 꺼진 줄(수식 탭과 같음)
const got = lineRows(l.i);
hasSub ||= got.sub;
if ((a.줄?.[l.i] as { 합밖?: boolean } | undefined)?.합밖) continue;
for (const [c, v] of Object.entries(got.cols)) {
sum[c] = Math.round((sum[c] + (v ?? 0)) * 10) / 10;
if (v) own[c].push(v);
}
}
}
loc = depth ? undefined : String(k);
if (hasSub && COSTS.some((c) => sum[c])) {
const s = Math.round((sum.노무비 + sum.재료비 + sum.경비) * 10) / 10;
push({ h: [`${lt("Sub_Sum")} : ${one(s)}`], cols: { sum: s, ...sum }, cls: "subsum" });
}
for (const c of COSTS) {
terms[c].push(...(hasSub ? (sum[c] ? [sum[c]] : []) : own[c]));
if (sum[c]) perItem[c].push(sum[c]);
}
}
lv = base + 1;
loc = undefined;
if (doc.결과) {
const r = a.결과;
push({
h: [
katexNode(symTex(doc.결과)),
` = ${isNum(r) ? `${eqText(r)} ${doc.결과단위 ?? ""}` : "—"}`,
],
cls: "total",
});
return out;
}
if (!money.length) return out;
push({ h: [`${++n}. ${lt("Total")}`], cls: "item" });
lv = base + 2;
for (const c of COSTS) {
const t = terms[c].length > LONG_SUM ? perItem[c] : terms[c];
const v = a[c];
const body = !isNum(v)
? "—"
: t.length > 1
? `${t.map(one).join(" + ")} = ${won(v)}`
: t.length
? won(v)
: "";
push({ h: [`${COST_SHOW[c]} : ${body}`] });
}
push({
h: [`${lt("Sum")} : ${isNum(a.계) ? won(a.계) : "—"}`],
cols: isNum(a.계)
? { sum: a.계, 노무비: a.노무비 ?? 0, 재료비: a.재료비 ?? 0, 경비: a.경비 ?? 0 }
: {},
cls: "total",
won: true,
});
return out;
}
const missing = (a: DocAnswer, id: string): HTMLElement =>
el("span", { className: "lstk-miss", text: `— ${a.빈값?.[id] ?? ""}`.trim() });
/** 산출근거 표 */
export function buildOut(doc: StackDoc, a: DocAnswer, h: OutHooks): HTMLElement {
const columns: Column[] = [
{ label: lt("Basis") },
{ label: lt("Total"), width: "92px", align: "end" },
{ label: COST_SHOW.노무비, width: "84px", align: "end" },
{ label: COST_SHOW.재료비, width: "84px", align: "end" },
{ label: COST_SHOW.경비, width: "84px", align: "end" },
];
const { root, tbody } = tableShell(columns, { density: "data" });
const head: Row = {
h: [`${doc.이름 ?? ""}${doc.결과단위 ? ` [${doc.결과단위}]` : ""}`],
cols: isNum(a.계)
? { sum: a.계, 노무비: a.노무비 ?? 0, 재료비: a.재료비 ?? 0, 경비: a.경비 ?? 0 }
: {},
cls: "head",
won: true,
depth: 0,
lv: 0,
};
for (const r of [head, ...rows(doc, a, h, 0, "")]) {
const cell = (k: "sum" | (typeof COSTS)[number]): string => {
const v = r.cols?.[k];
if (v === undefined || (r.won && !v && k !== "sum")) return "";
return r.won ? won(v) : one(v);
};
const first = el("div", { className: "lstk-h", children: r.h });
first.style.setProperty("--lstk-depth", String(r.lv));
first.dataset.lv = String(r.lv);
const tr = tableRow(columns, [first, cell("sum"), ...COSTS.map(cell)]);
tr.className = `lstk-orow ${r.cls ?? ""} ${r.depth ? "is-sub" : ""}`;
if (r.loc !== undefined) {
tr.dataset.loc = r.loc;
tr.addEventListener("click", () => h.pair?.(r.loc!));
}
tbody.append(tr);
}
root.classList.add("lstk-out");
return root;
}