Files
Aislo/tools/line-stack-test/render.js
T
eomsangdonandClaude Opus 5.5 3e64aa2a79 feat(804-1): 시험 페이지 「줄 쌓기 단가산출」 tools/line-stack-test + M01 왼쪽 패널 단추
- 왼쪽 줄 쌓기(항목 · 줄 일곱 종류 · 공식 틀 셋 · 식으로 풀기 · 틀로 저장 · 표에서 고르기 · 끌어 순서) · 오른쪽 경쟁사 꼴 산출근거(분수 기호식 = 값식 = 결과 · 소계 · 합계) · 아래 비목 상자 · 엑셀 글 붙여넣기 · 저장 = localStorage
- 견본 셋 금액 = 거창 파일 원 단위(5,323 · 52,796 · 94,215 비목 넷) · 확인 = node tools/line-stack-test/check.mjs
- 접근 = main.py 정적 마운트 /tools/line-stack-test · vite 프록시 한 줄(개발 포트에서 열림) · M01 왼쪽 패널 맨 아래 「시험: 줄 쌓기」 새 탭
- 라이브러리 0(분수 = CSS) · 지우기 = 폴더 + 표시 주석 셋 덩이

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

182 lines
6.6 KiB
JavaScript
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.
// 줄 쌓기 단가산출 시험 — 산출근거 출력(경쟁사 「단가산출」 시트 꼴 · 분수는 CSS 로 조판)
import { COLS, COL_NAME, parse, varsIn } from "./calc.js";
export const esc = (s) =>
String(s ?? "").replace(
/[&<>"]/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],
);
export const fmtP = (n) => Math.round(n).toLocaleString("en-US");
export const fmt1 = (n) =>
n.toLocaleString("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
export const fmtV = (n) =>
n == null || Number.isNaN(n) ? "?" : n.toLocaleString("en-US", { maximumFractionDigits: 4 });
const OP = { "*": "×", "/": "/", "×": "×", "÷": "÷", "+": "+", "-": "-", "^": "^" };
/** 식 나무 → 글(sym = 기호 · val = 값) · 기호가 든 식의 「/」 는 분수로 */
export function showAst(ast, mode, env, frac = varsIn(ast).length > 0) {
const go = (a) => showAst(a, mode, env, frac);
const bare = (a) => (a.t === "par" ? go(a.a) : go(a));
switch (ast.t) {
case "num":
return fmtV(ast.v);
case "var":
return mode === "sym"
? esc(ast.n)
: ast.n in env
? fmtV(env[ast.n])
: `<b class="bad">${esc(ast.n)}</b>`;
case "par":
return `( ${go(ast.a)} )`;
case "neg":
return `-${go(ast.a)}`;
}
if (ast.op === "/" && frac)
return `<span class="fr"><span>${bare(ast.a)}</span><span>${bare(ast.b)}</span></span>`;
return `${go(ast.a)} ${OP[ast.op]} ${go(ast.b)}`;
}
const opsText = (ops, env) =>
(ops ?? [])
.map((o) => {
let v;
try {
v = showAst(parse(o.v), "val", env);
} catch {
v = `<b class="bad">${esc(o.v)}</b>`;
}
return `${esc(OP[o.op] ?? o.op)} ${v}${o.u ? " " + esc(o.u) : ""}`;
})
.join(" ");
/** 공식 줄 — 값 줄은 넷까지 한 줄에 · 식 줄은 「이름 = 기호식 = 값식 = 결과 단위」 */
function formulaRows(ln, r, push) {
let pack = [];
const flush = () =>
pack.length && (push({ h: pack.join('<span class="gap"></span>') }), (pack = []));
(ln.rows ?? []).forEach((row, i) => {
const rr = r.rows?.[i];
if (!row.n?.trim() && !row.x?.toString().trim()) return;
const u = row.u ? " " + esc(row.u) : "";
const name = esc(row.n || "?");
if (!rr?.ast || rr.err) {
flush();
return push({ h: `${name} = ${esc(row.x)} <b class="bad">${esc(rr?.err ?? "")}</b>` });
}
const plain = rr.ast.t === "num";
const hasVar = varsIn(rr.ast).length > 0;
if (!hasVar) {
pack.push(
`${name} = ${showAst(rr.ast, "sym", rr.env)}${plain ? "" : " = " + fmtV(rr.val)}${u}`,
);
if (pack.length >= 4) flush();
return;
}
flush();
push({
h: `${name} = ${showAst(rr.ast, "sym", rr.env)} = ${showAst(rr.ast, "val", rr.env)} = ${fmtV(rr.val)}${u}`,
tall: /class="fr"/.test(showAst(rr.ast, "sym", rr.env)),
});
});
flush();
}
/** 출력 줄 목록 — {h, cols?, cls?} */
export function outRows(doc, res) {
const rows = [];
const push = (r) => rows.push(r);
push({
h: `${esc(doc.name)}${doc.unit ? ` [${esc(doc.unit)}]` : ""}`,
cols: { ...res.tot },
cls: "head",
won: true,
});
let n = 0;
let sub = 0;
(doc.items ?? []).forEach((item, ii) => {
const it = res.items[ii];
if (item.title?.trim()) {
const label = item.sub ? `${"가나다라마바사아자차카타파하"[sub++] ?? "?"} . ` : `${++n}. `;
if (!item.sub) sub = 0;
push({ h: label + esc(item.title), cls: item.sub ? "item sub" : "item" });
}
// 율 줄이 소계 이름을 가지면 그 앞 같은 비목 줄은 칸 값을 안 찍음(소계 줄이 찍음)
const hideCol = (li, col) =>
item.lines.some(
(x, j) =>
j > li && x.k === "rate" && x.baseName && x.scope !== "all" && (x.baseCol || "l") === col,
);
item.lines.forEach((ln, li) => {
const r = it.lines[li];
if (r.err) return push({ h: `<b class="bad">${esc(r.err)}</b>`, err: true });
if (ln.k === "text") return push({ h: esc(ln.text) });
if (ln.k === "formula") return formulaRows(ln, r, push);
if (ln.k === "mach" || ln.k === "ref") {
for (const c of COLS) {
if (r.cols[c] === undefined) continue;
push({
h: `${COL_NAME[c]} : ${fmtP(ln.res[c])} ${opsText(ln.cops?.[c] ?? ln.ops, r.vars)} = ${fmt1(r.cols[c])}`,
});
}
return;
}
if (ln.k === "labor" || ln.k === "mat") {
const col = ln.k === "labor" ? "l" : ln.col || "m";
const v = r.cols[col];
const sep = ln.k === "labor" ? " :" : "";
return push({
h: `${esc(ln.res?.name)}${sep} ${fmtP(Number(ln.res?.price) || 0)} ${opsText(ln.ops, r.vars)} = ${fmt1(v)}`,
cols: hideCol(li, col) ? null : { sum: v, [col]: v },
});
}
if (ln.k === "rate") {
const col = ln.col || "m";
const bc = ln.baseCol || "l";
if (ln.baseName && ln.scope !== "all")
push({
h: `${esc(ln.baseName)}소계 : ${fmt1(r.base)}`,
cols: { sum: r.base, [bc]: r.base },
cls: "subsum",
});
push({ h: `${esc(ln.name)} ( ${esc(ln.of || COL_NAME[bc])}의 ${esc(ln.rate)} % )` });
push({
h: `${fmt1(r.base)} × ${esc(ln.rate)} % = ${fmt1(r.cols[col])}`,
cols: { sum: r.cols[col], [col]: r.cols[col] },
});
}
});
if (it.hasSub && COLS.some((c) => it.sum[c])) {
const s = Math.round((it.sum.l + it.sum.m + it.sum.e) * 10) / 10;
push({ h: `소 계 : ${fmt1(s)}`, cols: { sum: s, ...it.sum }, cls: "subsum" });
}
});
push({ h: `${++n}. 합 계`, cls: "item" });
for (const c of COLS) {
const t = res.terms[c];
const body =
t.length > 1
? `${t.map(fmt1).join(" + ")} = ${fmtP(res.tot[c])}`
: t.length
? fmtP(res.tot[c])
: "";
push({ h: `${COL_NAME[c]} : ${body}` });
}
push({ h: `계 : ${fmtP(res.tot.sum)}`, cols: { ...res.tot }, cls: "total", won: true });
return rows;
}
export function renderOut(doc, res) {
const cell = (r, k) => {
const v = r.cols?.[k];
if (v === undefined || v === null || (r.won && !v && k !== "sum")) return "<td></td>";
return `<td class="n">${r.won ? fmtP(v) : fmt1(v)}</td>`;
};
const body = outRows(doc, res)
.map(
(r) =>
`<tr class="${r.cls ?? ""}${r.tall ? " tall" : ""}"><td class="h">${r.h}</td>${["sum", ...COLS].map((k) => cell(r, k)).join("")}</tr>`,
)
.join("");
return `<table class="out"><thead><tr><th>산 출 근 거</th><th>합 계</th><th>노무비</th><th>재료비</th><th>경 비</th></tr></thead><tbody>${body}</tbody></table>`;
}