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>
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
// 줄 쌓기 단가산출 시험 페이지(요구 669~676 · PLAN 804-1) — 서버 없음 · 저장 = 이 브라우저 localStorage
|
||||
// 지우기 = 이 폴더 삭제 + main.py 「/tools/line-stack-test」 마운트 덩이 + vite.config.ts 「/tools」 프록시 줄 + M01 왼쪽 패널 「시험: 줄 쌓기」 덩이
|
||||
import { compute } from "./calc.js";
|
||||
import { renderOut, fmtP, esc } from "./render.js";
|
||||
import { parsePaste } from "./paste.js";
|
||||
import { LABOR, MACHINE, MATERIAL, SANGEUN, SAMPLES, TABLE_E } from "./samples.js";
|
||||
import { renderEdit, allTemplates } from "./edit.js";
|
||||
|
||||
const KEY_DOC = "lineStackTest.doc";
|
||||
const KEY_TPL = "lineStackTest.myTemplates";
|
||||
const $ = (s) => document.querySelector(s);
|
||||
const clone = (x) => structuredClone(x);
|
||||
|
||||
let doc = clone(SAMPLES[0].doc);
|
||||
let expect = SAMPLES[0].expect;
|
||||
let expectFrom = SAMPLES[0].label;
|
||||
let mine = JSON.parse(localStorage.getItem(KEY_TPL) || "[]");
|
||||
let res = compute(doc);
|
||||
|
||||
const get = (path) => path.split(".").reduce((o, k) => o?.[k], doc);
|
||||
const setPath = (path, v) => {
|
||||
const ks = path.split(".");
|
||||
const last = ks.pop();
|
||||
ks.reduce((o, k) => o[k], doc)[last] = v;
|
||||
};
|
||||
|
||||
/** 칸 값만 바뀜 — 오른쪽 출력 · 합계 · 공식 값 칸만 다시(왼쪽은 그대로 → 입력 자리 안 잃음) */
|
||||
function update() {
|
||||
res = compute(doc);
|
||||
$("#out").innerHTML = renderOut(doc, res);
|
||||
const t = res.tot;
|
||||
$("#tot").innerHTML =
|
||||
["l", "m", "e"]
|
||||
.map(
|
||||
(c) =>
|
||||
`<div class="box"><span>${{ l: "노무비", m: "재료비", e: "경비" }[c]}</span><b>${fmtP(t[c])}</b></div>`,
|
||||
)
|
||||
.join("") + `<div class="box sum"><span>계</span><b>${fmtP(t.sum)}</b></div>`;
|
||||
const same = expect && ["l", "m", "e", "sum"].every((c) => t[c] === expect[c]);
|
||||
$("#cmp").innerHTML = expect
|
||||
? `${esc(expectFrom)} 거창 파일 ${fmtP(expect.sum)} (노 ${fmtP(expect.l)} · 재 ${fmtP(expect.m)} · 경 ${fmtP(expect.e)}) → <b class="${same ? "ok" : "bad"}">${same ? "같음" : "다름"}</b>`
|
||||
: "";
|
||||
document.querySelectorAll("[data-val]").forEach((el) => {
|
||||
const m = el.dataset.val.match(/^items\.(\d+)\.lines\.(\d+)\.rows\.(\d+)$/);
|
||||
const rr = res.items[m[1]]?.lines[m[2]]?.rows?.[m[3]];
|
||||
el.textContent = rr?.err ?? rr?.val ?? "";
|
||||
el.classList.toggle("bad", !!rr?.err);
|
||||
el.parentElement.querySelector(".expr")?.classList.toggle("bad", !!rr?.err);
|
||||
});
|
||||
}
|
||||
|
||||
/** 줄 더함 · 지움 · 옮김 등 꼴이 바뀜 — 왼쪽까지 다시 */
|
||||
function redraw() {
|
||||
res = compute(doc);
|
||||
$("#edit").innerHTML = renderEdit(doc, res, mine);
|
||||
update();
|
||||
}
|
||||
|
||||
function newLine(kind) {
|
||||
const [k, tplId] = kind.split(":");
|
||||
if (k === "formula") {
|
||||
const tpl = allTemplates(mine).find((t) => t.id === tplId);
|
||||
return {
|
||||
k,
|
||||
tpl: tpl ? tpl.id : null,
|
||||
rows: tpl ? clone(tpl.rows) : [{ n: "", x: "", u: "" }],
|
||||
open: !tpl,
|
||||
};
|
||||
}
|
||||
if (k === "mach") return { k, res: { ...MACHINE[0] }, ops: [{ op: "÷", v: "Q", u: "" }] };
|
||||
if (k === "ref") return { k, res: { ...SANGEUN[0] }, ops: [{ op: "×", v: "Q", u: "" }] };
|
||||
if (k === "labor") return { k, res: { ...LABOR[0] }, ops: [{ op: "×", v: "1", u: "인" }] };
|
||||
if (k === "mat")
|
||||
return { k, col: "m", res: { ...MATERIAL[0] }, ops: [{ op: "×", v: "1", u: "" }] };
|
||||
if (k === "rate")
|
||||
return {
|
||||
k,
|
||||
name: "공구손료 및 경장비",
|
||||
of: "노무비",
|
||||
baseName: "",
|
||||
baseCol: "l",
|
||||
scope: "item",
|
||||
rate: "3",
|
||||
col: "m",
|
||||
};
|
||||
return { k: "text", text: "" };
|
||||
}
|
||||
|
||||
function loadSample(s) {
|
||||
doc = clone(s.doc);
|
||||
expect = s.expect;
|
||||
expectFrom = s.label;
|
||||
redraw();
|
||||
}
|
||||
|
||||
// ── 머리 단추
|
||||
$("#samples").innerHTML = SAMPLES.map(
|
||||
(s, i) => `<button data-sample="${i}">${esc(s.label)}</button>`,
|
||||
).join("");
|
||||
$("#samples").addEventListener("click", (e) => {
|
||||
const i = e.target.dataset.sample;
|
||||
if (i !== undefined) loadSample(SAMPLES[i]);
|
||||
});
|
||||
$("#save").onclick = () => {
|
||||
localStorage.setItem(KEY_DOC, JSON.stringify({ doc, expect, expectFrom }));
|
||||
$("#msg").textContent = "이 브라우저에 저장함";
|
||||
};
|
||||
$("#load").onclick = () => {
|
||||
const s = JSON.parse(localStorage.getItem(KEY_DOC) || "null");
|
||||
if (!s) return void ($("#msg").textContent = "저장한 것 없음");
|
||||
({ doc, expect, expectFrom } = s);
|
||||
redraw();
|
||||
$("#msg").textContent = "불러옴";
|
||||
};
|
||||
|
||||
// ── 왼쪽 칸 입력
|
||||
$("#edit").addEventListener("input", (e) => {
|
||||
const p = e.target.dataset.p;
|
||||
if (!p) return;
|
||||
setPath(p, e.target.type === "checkbox" ? e.target.checked : e.target.value);
|
||||
if (e.target.type === "checkbox") return redraw();
|
||||
update();
|
||||
if (/\.rows\.\d+\.[nx]$/.test(p)) {
|
||||
// 틀 이름표(「내 틀 … 에서 변경」)만 다시 — 입력 자리는 그대로
|
||||
const lp = p.replace(/\.rows\.\d+\.[nx]$/, "");
|
||||
const card = e.target.closest(".line");
|
||||
const fresh = document.createElement("div");
|
||||
fresh.innerHTML = renderEdit(doc, res, mine);
|
||||
const [, i, j] = lp.match(/items\.(\d+)\.lines\.(\d+)/);
|
||||
const tag = fresh.querySelector(`.line[data-loc="${i}.${j}"] .tpl`);
|
||||
if (tag) card.querySelector(".tpl").innerHTML = tag.innerHTML;
|
||||
}
|
||||
});
|
||||
|
||||
$("#edit").addEventListener("change", (e) => {
|
||||
const act = e.target.dataset.act;
|
||||
if (!act || !e.target.value) return;
|
||||
if (act === "addline") {
|
||||
doc.items[e.target.dataset.i].lines.push(newLine(e.target.value));
|
||||
return redraw();
|
||||
}
|
||||
if (act.startsWith("res:")) {
|
||||
const [, kind, p] = act.split(":");
|
||||
const list = { mach: MACHINE, ref: SANGEUN, labor: LABOR, mat: MATERIAL }[kind];
|
||||
get(p).res = { ...list[e.target.value] };
|
||||
return redraw();
|
||||
}
|
||||
});
|
||||
|
||||
$("#edit").addEventListener("click", (e) => {
|
||||
const b = e.target.closest("button");
|
||||
if (!b) return;
|
||||
const { act, p, k, loc, i } = b.dataset;
|
||||
const at = loc?.split(".").map(Number);
|
||||
if (act === "additem") doc.items.push({ title: "", lines: [] });
|
||||
else if (act === "delitem") doc.items.splice(Number(i), 1);
|
||||
else if (act === "delline") doc.items[at[0]].lines.splice(at[1], 1);
|
||||
else if (act === "addop") (get(p).ops ??= []).push({ op: "×", v: "", u: "" });
|
||||
else if (act === "delop") get(p).ops.splice(Number(k), 1);
|
||||
else if (act === "addrow") get(p).rows.push({ n: "", x: "", u: "" });
|
||||
else if (act === "delrow") get(p).rows.splice(Number(k), 1);
|
||||
else if (act === "fold") get(p).open = !get(p).open;
|
||||
else if (act === "savetpl") {
|
||||
const ln = get(p);
|
||||
const name = prompt("틀 이름", "내 틀");
|
||||
if (!name?.trim()) return;
|
||||
const id = `my${Date.now()}`;
|
||||
mine.push({ id, name: name.trim(), rows: clone(ln.rows).map(({ src, ...r }) => r) });
|
||||
localStorage.setItem(KEY_TPL, JSON.stringify(mine));
|
||||
ln.tpl = id;
|
||||
} else if (act === "table") return openTable(p);
|
||||
else return;
|
||||
redraw();
|
||||
});
|
||||
|
||||
// ── 끌어 순서 바꿈(줄 위 = 그 앞 · 항목 머리 = 그 항목 끝)
|
||||
let dragFrom = null;
|
||||
$("#edit").addEventListener("dragstart", (e) => {
|
||||
dragFrom = e.target.dataset?.loc?.split(".").map(Number) ?? null;
|
||||
e.dataTransfer?.setData("text/plain", "line");
|
||||
});
|
||||
$("#edit").addEventListener("dragover", (e) => {
|
||||
if (dragFrom && e.target.closest(".line, [data-drop]")) e.preventDefault();
|
||||
});
|
||||
$("#edit").addEventListener("drop", (e) => {
|
||||
if (!dragFrom) return;
|
||||
e.preventDefault();
|
||||
const [fi, fj] = dragFrom;
|
||||
dragFrom = null;
|
||||
const ln = doc.items[fi].lines.splice(fj, 1)[0];
|
||||
const card = e.target.closest(".line");
|
||||
if (card) {
|
||||
let [ti, tj] = card.dataset.loc.split(".").map(Number);
|
||||
if (ti === fi && tj > fj) tj--;
|
||||
doc.items[ti].lines.splice(tj, 0, ln);
|
||||
} else {
|
||||
doc.items[Number(e.target.closest("[data-drop]").dataset.drop)].lines.push(ln);
|
||||
}
|
||||
redraw();
|
||||
});
|
||||
|
||||
// ── 표에서 고르기(견본 표 하나) — 값 + 출처 꼬리표
|
||||
function openTable(p) {
|
||||
const dlg = $("#tbl");
|
||||
dlg.querySelector("h3").textContent = TABLE_E.title;
|
||||
dlg.querySelector("table").innerHTML =
|
||||
`<tr>${TABLE_E.head.map((h) => `<th>${esc(h)}</th>`).join("")}</tr>` +
|
||||
TABLE_E.rows
|
||||
.map(
|
||||
(r) =>
|
||||
`<tr><th>${esc(r[0])}</th>${r
|
||||
.slice(1)
|
||||
.map((v, c) =>
|
||||
v == null
|
||||
? "<td></td>"
|
||||
: `<td><button data-v="${v}" data-src="${esc(`${TABLE_E.title} · ${r[0]} · ${TABLE_E.head[c + 1]}`)}">${v}</button></td>`,
|
||||
)
|
||||
.join("")}</tr>`,
|
||||
)
|
||||
.join("");
|
||||
dlg.onclick = (e) => {
|
||||
const b = e.target.closest("button[data-v]");
|
||||
if (b) {
|
||||
const row = get(p);
|
||||
row.x = b.dataset.v;
|
||||
row.src = b.dataset.src;
|
||||
dlg.close();
|
||||
redraw();
|
||||
} else if (e.target.closest("[data-close]")) dlg.close();
|
||||
};
|
||||
dlg.showModal();
|
||||
}
|
||||
|
||||
// ── 엑셀 글 붙여넣기
|
||||
$("#pasteGo").onclick = () => {
|
||||
const text = $("#paste").value;
|
||||
if (!text.trim()) return void ($("#pasteMsg").textContent = "붙일 글이 없음");
|
||||
const { doc: d, expect: ex, stat } = parsePaste(text);
|
||||
doc = d;
|
||||
expect = ex;
|
||||
expectFrom = "붙여넣은 블록";
|
||||
redraw();
|
||||
$("#pasteMsg").textContent =
|
||||
`읽은 줄 ${stat.read} · 글 줄 ${stat.text}${ex ? "" : " · 머리 합계 없음(비교 안 함)"}`;
|
||||
};
|
||||
|
||||
redraw();
|
||||
@@ -0,0 +1,182 @@
|
||||
// 줄 쌓기 단가산출 시험 — 계산(화면 없음 · node 로도 돎)
|
||||
// 끝수 = 거창 출력과 같은 자리: 줄 값 0.1 자리 버림 · 비목 합계 원 단위 버림 · 공식 변수(식인 줄) 소수 둘째 반올림
|
||||
export const COLS = ["l", "m", "e"];
|
||||
export const COL_NAME = { l: "노무비", m: "재료비", e: "경 비" };
|
||||
|
||||
const TOKEN = /\s*(?:(\d[\d,]*\.?\d*|\.\d+)|([A-Za-z가-힣_][A-Za-z0-9가-힣_]*)|([+\-*/×÷^()]))/y;
|
||||
|
||||
export function tokenize(s) {
|
||||
const out = [];
|
||||
TOKEN.lastIndex = 0;
|
||||
const src = String(s ?? "").trim();
|
||||
while (TOKEN.lastIndex < src.length) {
|
||||
const at = TOKEN.lastIndex;
|
||||
const m = TOKEN.exec(src);
|
||||
if (!m || m.index !== at) throw new Error(`읽을 수 없는 글자: ${src.slice(at).trim()[0]}`);
|
||||
if (m[1] !== undefined) out.push({ t: "num", v: Number(m[1].replace(/,/g, "")) });
|
||||
else if (m[2] !== undefined) out.push({ t: "var", n: m[2] });
|
||||
else out.push({ t: "op", v: m[3] });
|
||||
if (TOKEN.lastIndex >= src.length || !src.slice(TOKEN.lastIndex).trim()) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 식 → 나무(괄호는 그대로 남겨 화면에 쓴 꼴대로 보임) */
|
||||
export function parse(s) {
|
||||
const tk = tokenize(s);
|
||||
if (!tk.length) throw new Error("빈 식");
|
||||
let i = 0;
|
||||
const peek = () => tk[i];
|
||||
const isOp = (...ops) => peek()?.t === "op" && ops.includes(peek().v);
|
||||
const add = () => {
|
||||
let a = mul();
|
||||
while (isOp("+", "-")) a = { t: "bin", op: tk[i++].v, a, b: mul() };
|
||||
return a;
|
||||
};
|
||||
const mul = () => {
|
||||
let a = unary();
|
||||
while (isOp("*", "/", "×", "÷")) a = { t: "bin", op: tk[i++].v, a, b: unary() };
|
||||
return a;
|
||||
};
|
||||
const unary = () => (isOp("-") ? (i++, { t: "neg", a: unary() }) : pow());
|
||||
const pow = () => {
|
||||
const a = atom();
|
||||
return isOp("^") ? (i++, { t: "bin", op: "^", a, b: unary() }) : a;
|
||||
};
|
||||
const atom = () => {
|
||||
const k = tk[i++];
|
||||
if (!k) throw new Error("식이 덜 끝남");
|
||||
if (k.t === "num" || k.t === "var") return k;
|
||||
if (k.v === "(") {
|
||||
const a = add();
|
||||
if (!isOp(")")) throw new Error("괄호가 안 닫힘");
|
||||
i++;
|
||||
return { t: "par", a };
|
||||
}
|
||||
throw new Error(`「${k.v}」 자리가 틀림`);
|
||||
};
|
||||
const ast = add();
|
||||
if (i < tk.length) throw new Error(`「${tk[i].v ?? tk[i].n}」 앞에서 식이 끊김`);
|
||||
return ast;
|
||||
}
|
||||
|
||||
export function varsIn(ast, out = []) {
|
||||
if (!ast) return out;
|
||||
if (ast.t === "var") out.includes(ast.n) || out.push(ast.n);
|
||||
for (const k of ["a", "b"]) if (ast[k]) varsIn(ast[k], out);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function evalAst(ast, vars) {
|
||||
switch (ast.t) {
|
||||
case "num":
|
||||
return ast.v;
|
||||
case "var":
|
||||
if (!(ast.n in vars)) throw new Error(`없는 이름: ${ast.n}`);
|
||||
return vars[ast.n];
|
||||
case "par":
|
||||
return evalAst(ast.a, vars);
|
||||
case "neg":
|
||||
return -evalAst(ast.a, vars);
|
||||
}
|
||||
const a = evalAst(ast.a, vars);
|
||||
const b = evalAst(ast.b, vars);
|
||||
if (ast.op === "+") return a + b;
|
||||
if (ast.op === "-") return a - b;
|
||||
if (ast.op === "*" || ast.op === "×") return a * b;
|
||||
if (ast.op === "^") return a ** b;
|
||||
return a / b;
|
||||
}
|
||||
|
||||
export const floor1 = (x) => Math.floor(x * 10 + 1e-6) / 10;
|
||||
export const floor0 = (x) => Math.floor(x + 1e-6);
|
||||
export const round1 = (x) => Math.round(x * 10) / 10;
|
||||
export const roundTo = (x, d) => Math.round(x * 10 ** d + 1e-9) / 10 ** d;
|
||||
|
||||
/** 곱나눔 꼬리 하나 값(% 면 100 으로 나눔) */
|
||||
export function opValue(op, vars) {
|
||||
const v = evalAst(parse(op.v), vars);
|
||||
return op.u?.trim() === "%" ? v / 100 : v;
|
||||
}
|
||||
|
||||
export function applyOps(x, ops, vars) {
|
||||
for (const op of ops ?? []) {
|
||||
const v = opValue(op, vars);
|
||||
x = op.op === "×" || op.op === "*" ? x * v : x / v;
|
||||
}
|
||||
return x;
|
||||
}
|
||||
|
||||
const isPlain = (ast) => ast.t === "num" || (ast.t === "neg" && ast.a.t === "num");
|
||||
|
||||
/** 단가산출 하나를 셈 — 줄마다 결과 · 항목 소계 · 합계 항(경쟁사 꼴) · 비목 합계 */
|
||||
export function compute(doc) {
|
||||
const vars = {};
|
||||
const terms = { l: [], m: [], e: [] };
|
||||
const all = { l: 0, m: 0, e: 0 };
|
||||
const items = [];
|
||||
const errors = [];
|
||||
for (const item of doc.items ?? []) {
|
||||
const it = { lines: [], sum: { l: 0, m: 0, e: 0 }, terms: { l: [], m: [], e: [] } };
|
||||
it.hasSub = (item.lines ?? []).some((ln) => ln.k === "mach" || ln.k === "ref");
|
||||
const put = (col, val, r) => {
|
||||
it.sum[col] = round1(it.sum[col] + val);
|
||||
it.terms[col].push(val);
|
||||
r.cols[col] = val;
|
||||
};
|
||||
for (const ln of item.lines ?? []) {
|
||||
const r = { cols: {}, err: null, vars: { ...vars } };
|
||||
try {
|
||||
if (ln.k === "formula") {
|
||||
r.rows = (ln.rows ?? []).map((row) => calcRow(row, vars));
|
||||
} else if (ln.k === "mach" || ln.k === "ref") {
|
||||
for (const c of COLS) {
|
||||
const price = Number(ln.res?.[c]) || 0;
|
||||
if (price) put(c, floor1(applyOps(price, ln.cops?.[c] ?? ln.ops, vars)), r);
|
||||
}
|
||||
} else if (ln.k === "labor" || ln.k === "mat") {
|
||||
const col = ln.k === "labor" ? "l" : ln.col || "m";
|
||||
put(col, floor1(applyOps(Number(ln.res?.price) || 0, ln.ops, vars)), r);
|
||||
} else if (ln.k === "rate") {
|
||||
const bc = ln.baseCol || "l";
|
||||
const base = round1(ln.scope === "all" ? all[bc] + it.sum[bc] : it.sum[bc]);
|
||||
r.base = base;
|
||||
if (ln.baseName && ln.scope !== "all") it.terms[bc] = base ? [base] : [];
|
||||
put(ln.col || "m", floor1((base * Number(ln.rate || 0)) / 100), r);
|
||||
}
|
||||
} catch (e) {
|
||||
r.err = e.message;
|
||||
errors.push(e.message);
|
||||
}
|
||||
it.lines.push(r);
|
||||
}
|
||||
for (const c of COLS) {
|
||||
all[c] = round1(all[c] + it.sum[c]);
|
||||
terms[c].push(...(it.hasSub ? (it.sum[c] ? [it.sum[c]] : []) : it.terms[c]));
|
||||
}
|
||||
items.push(it);
|
||||
}
|
||||
const tot = { l: floor0(all.l), m: floor0(all.m), e: floor0(all.e) };
|
||||
tot.sum = tot.l + tot.m + tot.e;
|
||||
return { items, terms, all, tot, errors, vars };
|
||||
}
|
||||
|
||||
/** 공식 줄 한 칸 — 「이름 = 식」 · 없는 이름은 unknown 으로(빨강) */
|
||||
export function calcRow(row, vars) {
|
||||
const r = { val: null, err: null, unknown: [], env: { ...vars } };
|
||||
if (!row.x?.toString().trim()) return r;
|
||||
try {
|
||||
r.ast = parse(row.x);
|
||||
r.unknown = varsIn(r.ast).filter((n) => !(n in vars));
|
||||
if (r.unknown.length) {
|
||||
r.err = `없는 이름: ${r.unknown.join(", ")}`;
|
||||
return r;
|
||||
}
|
||||
const v = evalAst(r.ast, vars);
|
||||
r.val = isPlain(r.ast) ? v : roundTo(v, row.d ?? 2);
|
||||
if (row.n?.trim()) vars[row.n.trim()] = r.val;
|
||||
} catch (e) {
|
||||
r.err = e.message;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// node tools/line-stack-test/check.mjs — 견본 셋 금액 = 거창 출력 숫자(원 단위)
|
||||
import { compute } from "./calc.js";
|
||||
import { SAMPLES } from "./samples.js";
|
||||
let bad = 0;
|
||||
for (const s of SAMPLES.filter((x) => x.expect)) {
|
||||
const { tot, errors } = compute(s.doc);
|
||||
const ok = ["l", "m", "e", "sum"].every((c) => tot[c] === s.expect[c]) && !errors.length;
|
||||
if (!ok) bad++;
|
||||
console.log(ok ? "같음" : "다름", s.label, JSON.stringify(tot), errors.join(" "));
|
||||
}
|
||||
process.exit(bad ? 1 : 0);
|
||||
@@ -0,0 +1,165 @@
|
||||
// 줄 쌓기 단가산출 시험 — 왼쪽 줄 쌓기 편집(종류마다 필요한 칸만 · 칸은 data-p 경로로 doc 에 바로 씀)
|
||||
import { LABOR, MACHINE, MATERIAL, SANGEUN, TEMPLATES } from "./samples.js";
|
||||
import { esc } from "./render.js";
|
||||
|
||||
export const KINDS = {
|
||||
mach: "① 기계",
|
||||
ref: "② 다른 단산 참조",
|
||||
labor: "③ 인력",
|
||||
rate: "④ 율",
|
||||
mat: "⑤ 재료",
|
||||
formula: "⑥ 공식",
|
||||
text: "⑦ 글 · 근거",
|
||||
};
|
||||
const COLSEL = { l: "노무비", m: "재료비", e: "경비" };
|
||||
|
||||
const inp = (p, v, cls = "", ph = "") =>
|
||||
`<input data-p="${p}" value="${esc(v ?? "")}" class="${cls}" placeholder="${esc(ph)}" />`;
|
||||
const sel = (p, v, opts) =>
|
||||
`<select data-p="${p}">${Object.entries(opts)
|
||||
.map(([k, t]) => `<option value="${k}"${k === v ? " selected" : ""}>${esc(t)}</option>`)
|
||||
.join("")}</select>`;
|
||||
const pick = (list, act, label) =>
|
||||
`<select data-act="${act}"><option value="">${label}</option>${list
|
||||
.map((r, i) => `<option value="${i}">${esc(r.no ? `단산 ${r.no} ${r.name}` : r.name)}</option>`)
|
||||
.join("")}</select>`;
|
||||
|
||||
export function allTemplates(mine) {
|
||||
return [...TEMPLATES, ...mine];
|
||||
}
|
||||
|
||||
/** 「+ 줄」 고르개 — 공식은 틀마다 하나씩 */
|
||||
export function addLineSelect(i, mine) {
|
||||
const tpls = allTemplates(mine)
|
||||
.map((t) => `<option value="formula:${t.id}">⑥ 공식 — ${esc(t.name)}</option>`)
|
||||
.join("");
|
||||
return `<select data-act="addline" data-i="${i}"><option value="">+ 줄 ▾</option>
|
||||
<option value="mach">${KINDS.mach}</option><option value="ref">${KINDS.ref}</option>
|
||||
<option value="labor">${KINDS.labor}</option><option value="rate">${KINDS.rate}</option>
|
||||
<option value="mat">${KINDS.mat}</option>${tpls}
|
||||
<option value="formula:">⑥ 공식 — 새 공식(빈 묶음)</option><option value="text">${KINDS.text}</option></select>`;
|
||||
}
|
||||
|
||||
function opsEdit(p, ops) {
|
||||
const one = (o, k) =>
|
||||
`<span class="op">${sel(`${p}.ops.${k}.op`, o.op, { "×": "×", "÷": "÷", "/": "/" })}${inp(
|
||||
`${p}.ops.${k}.v`,
|
||||
o.v,
|
||||
"v",
|
||||
"값 · 이름",
|
||||
)}${inp(`${p}.ops.${k}.u`, o.u, "u", "단위")}<button data-act="delop" data-p="${p}" data-k="${k}" title="지움">×</button></span>`;
|
||||
return `<div class="ops">${(ops ?? []).map(one).join("")}<button data-act="addop" data-p="${p}">+ 곱나눔</button></div>`;
|
||||
}
|
||||
|
||||
const copsNote = (ln) =>
|
||||
ln.cops
|
||||
? `<div class="note">비목 따로 꼬리(붙여넣기): ${Object.entries(ln.cops)
|
||||
.map(
|
||||
([c, ops]) =>
|
||||
`${COLSEL[c]} ${ops.map((o) => `${o.op} ${esc(o.v)} ${esc(o.u)}`).join(" ")}`,
|
||||
)
|
||||
.join(" · ")}</div>`
|
||||
: "";
|
||||
|
||||
/** 공식 줄 — 접힘 = 값 칸만 · 풀림 = 「이름 = 식」 글 줄 전부 */
|
||||
function formulaEdit(p, ln, r, mine) {
|
||||
const tpl = allTemplates(mine).find((t) => t.id === ln.tpl);
|
||||
const changed = tpl && JSON.stringify(shape(tpl.rows)) !== JSON.stringify(shape(ln.rows));
|
||||
const label = !tpl
|
||||
? ln.tpl === null && !ln.rows?.some((x) => x.x)
|
||||
? "새 공식"
|
||||
: "내 공식"
|
||||
: changed
|
||||
? `내 틀 (${esc(tpl.name)} 에서 변경)`
|
||||
: esc(tpl.name);
|
||||
const rows = (ln.rows ?? [])
|
||||
.map((row, k) => {
|
||||
const rr = r?.rows?.[k] ?? {};
|
||||
const rp = `${p}.rows.${k}`;
|
||||
const plain = /^-?[\d.]+$/.test(String(row.x ?? "").trim());
|
||||
const val = `<span class="val ${rr.err ? "bad" : ""}" data-val="${rp}">${rr.err ? esc(rr.err) : (rr.val ?? "")}</span>`;
|
||||
const src = row.src ? `<span class="src" title="${esc(row.src)}">표</span>` : "";
|
||||
const table = `<button data-act="table" data-p="${rp}" title="표에서 고르기">표</button>`;
|
||||
if (!ln.open) {
|
||||
if (plain || !row.x)
|
||||
return `<div class="frow"><b>${esc(row.n || "?")}</b> = ${inp(`${rp}.x`, row.x, "num")} <i>${esc(row.u)}</i> ${table}${src}</div>`;
|
||||
return `<div class="frow calc"><b>${esc(row.n)}</b> = <code>${esc(row.x)}</code> = ${val} <i>${esc(row.u)}</i></div>`;
|
||||
}
|
||||
return `<div class="frow open">${inp(`${rp}.n`, row.n, "name", "이름")} = ${inp(`${rp}.x`, row.x, `expr ${rr.err ? "bad" : ""}`, "값 또는 식")}${inp(
|
||||
`${rp}.u`,
|
||||
row.u,
|
||||
"u",
|
||||
"단위",
|
||||
)}${val}${table}${src}<button data-act="delrow" data-p="${p}" data-k="${k}" title="줄 지움">×</button></div>`;
|
||||
})
|
||||
.join("");
|
||||
return `<div class="fhead"><span class="tpl">${label}</span>
|
||||
<button data-act="fold" data-p="${p}">${ln.open ? "접기" : "식으로 풀기"}</button>
|
||||
<button data-act="savetpl" data-p="${p}">틀로 저장</button></div>${rows}
|
||||
${ln.open ? `<button data-act="addrow" data-p="${p}">+ 「이름 = 식」 줄</button>` : ""}`;
|
||||
}
|
||||
|
||||
/** 틀과 견줄 꼴 — 값 줄은 이름만 · 식 줄은 이름과 식 */
|
||||
export function shape(rows) {
|
||||
return (rows ?? []).map((x) =>
|
||||
/^-?[\d.]+$/.test(String(x.x).trim()) ? [x.n] : [x.n, String(x.x).replace(/\s/g, "")],
|
||||
);
|
||||
}
|
||||
|
||||
function lineBody(p, ln, r, mine) {
|
||||
const price3 = () =>
|
||||
`<div class="row">노무 ${inp(`${p}.res.l`, ln.res?.l, "num")} 재료 ${inp(`${p}.res.m`, ln.res?.m, "num")} 경비 ${inp(
|
||||
`${p}.res.e`,
|
||||
ln.res?.e,
|
||||
"num",
|
||||
)}</div>`;
|
||||
switch (ln.k) {
|
||||
case "mach":
|
||||
return `<div class="row">${pick(MACHINE, `res:mach:${p}`, "견본 기계 ▾")}${inp(`${p}.res.name`, ln.res?.name, "wide", "기계 이름")}</div>${price3()}${opsEdit(p, ln.ops)}${copsNote(ln)}`;
|
||||
case "ref":
|
||||
return `<div class="row">${pick(SANGEUN, `res:ref:${p}`, "견본 단산 ▾")} 단산 ${inp(`${p}.res.no`, ln.res?.no, "num s")}${inp(
|
||||
`${p}.res.name`,
|
||||
ln.res?.name,
|
||||
"wide",
|
||||
"단가산출 이름",
|
||||
)}</div>${price3()}${opsEdit(p, ln.ops)}${copsNote(ln)}`;
|
||||
case "labor":
|
||||
case "mat":
|
||||
return `<div class="row">${pick(ln.k === "labor" ? LABOR : MATERIAL, `res:${ln.k}:${p}`, ln.k === "labor" ? "견본 노임 ▾" : "견본 자재 ▾")}${inp(
|
||||
`${p}.res.name`,
|
||||
ln.res?.name,
|
||||
"wide",
|
||||
"이름",
|
||||
)} 단가 ${inp(`${p}.res.price`, ln.res?.price, "num")}${ln.k === "mat" ? ` → ${sel(`${p}.col`, ln.col || "m", COLSEL)}` : ""}</div>${opsEdit(p, ln.ops)}`;
|
||||
case "rate":
|
||||
return `<div class="row">${inp(`${p}.name`, ln.name, "wide", "이름(예: 공구손료 및 경장비)")}</div>
|
||||
<div class="row">기준 ${sel(`${p}.scope`, ln.scope || "item", { item: "이 항목 위 줄", all: "위 전부" })} 의 ${sel(`${p}.baseCol`, ln.baseCol || "l", COLSEL)}
|
||||
× ${inp(`${p}.rate`, ln.rate, "num s")} % → ${sel(`${p}.col`, ln.col || "m", COLSEL)}</div>
|
||||
<div class="row">글 「( ${inp(`${p}.of`, ln.of, "s2", "노무비")}의 % )」 · 소계 이름 ${inp(`${p}.baseName`, ln.baseName, "s2", "비우면 안 찍음")}</div>`;
|
||||
case "formula":
|
||||
return formulaEdit(p, ln, r, mine);
|
||||
case "text":
|
||||
return inp(`${p}.text`, ln.text, "full", "< 품셈 근거 > · ※ 메모 · 별산");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function renderEdit(doc, res, mine) {
|
||||
const items = (doc.items ?? [])
|
||||
.map((item, i) => {
|
||||
const lines = (item.lines ?? [])
|
||||
.map((ln, j) => {
|
||||
const p = `items.${i}.lines.${j}`;
|
||||
const r = res.items[i]?.lines[j];
|
||||
return `<div class="line k-${ln.k}" data-loc="${i}.${j}"><div class="lh"><span class="grip" draggable="true" data-loc="${i}.${j}" title="끌어 순서 바꿈">⠿</span><span class="kind">${KINDS[ln.k]}</span>${
|
||||
r?.err ? `<span class="bad">${esc(r.err)}</span>` : ""
|
||||
}<button data-act="delline" data-loc="${i}.${j}" title="줄 지움">지움</button></div>${lineBody(p, ln, r, mine)}</div>`;
|
||||
})
|
||||
.join("");
|
||||
return `<div class="item" data-i="${i}"><div class="ih" data-drop="${i}">${inp(`items.${i}.title`, item.title, "title", "항목 이름(비우면 번호 없음)")}
|
||||
<label><input type="checkbox" data-p="items.${i}.sub" ${item.sub ? "checked" : ""}/>가나다</label>${addLineSelect(i, mine)}
|
||||
<button data-act="delitem" data-i="${i}">항목 지움</button></div>${lines || '<div class="empty" data-drop="' + i + '">줄 없음 — [+ 줄 ▾]</div>'}</div>`;
|
||||
})
|
||||
.join("");
|
||||
return `<div class="dochead">이름 ${inp("name", doc.name, "wide")} 단위 ${inp("unit", doc.unit, "s")}</div>${items}<button data-act="additem">+ 항목</button>`;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>시험: 줄 쌓기 단가산출</title>
|
||||
<link rel="icon" href="data:," />
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>시험: 줄 쌓기 단가산출</h1>
|
||||
<span class="hint">서버 없음 · 저장은 이 브라우저에만 · 마음에 안 들면 폴더째 지움</span>
|
||||
<span id="samples"></span>
|
||||
<button id="save">저장</button><button id="load">불러오기</button><span id="msg"></span>
|
||||
</header>
|
||||
<main>
|
||||
<section id="edit"></section>
|
||||
<section id="outWrap"><div id="out"></div></section>
|
||||
</main>
|
||||
<footer>
|
||||
<div id="tot"></div>
|
||||
<div id="cmp"></div>
|
||||
<details>
|
||||
<summary>
|
||||
엑셀 글 붙여넣기 — 경쟁사 「단가산출」 시트 한 블록(머리 줄부터 「계 :」 줄까지)
|
||||
</summary>
|
||||
<textarea
|
||||
id="paste"
|
||||
rows="8"
|
||||
placeholder="1.측구터파기(토사)[㎥] 5323 3,442 832 1,049 1. 기 계 ( 100 % ) : 굴삭기(무한궤도) (0.7㎥) …"
|
||||
></textarea>
|
||||
<button id="pasteGo">줄로 넣기</button><span id="pasteMsg"></span>
|
||||
</details>
|
||||
</footer>
|
||||
<dialog id="tbl">
|
||||
<h3></h3>
|
||||
<table></table>
|
||||
<button data-close>닫기</button>
|
||||
</dialog>
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,257 @@
|
||||
import { compute } from "./calc.js";
|
||||
// 줄 쌓기 단가산출 시험 — 엑셀 「단가산출」 블록 글 → 줄(일곱 종류로 읽히는 만큼 · 못 읽는 줄은 글 줄)
|
||||
const NUM = /^-?[\d,]*\.?\d+\.?$|^-?\d[\d,]*\.$/;
|
||||
const isNum = (t) => NUM.test(t);
|
||||
const num = (t) => Number(String(t).replace(/,/g, "").replace(/\.$/, ""));
|
||||
const clean = (t) =>
|
||||
String(t)
|
||||
.replace(/(\d)\.(?!\d)/g, "$1")
|
||||
.replace(/,(?=\d{3})/g, "");
|
||||
const OPS = ["×", "÷", "/", "*"];
|
||||
const KEY = { 노무비: "l", 재료비: "m", 경비: "e" };
|
||||
|
||||
/** 「× 4 인 ÷ 170 ㎡」 → 꼬리 [{op, v, u}] · 괄호 등 못 읽으면 null */
|
||||
export function parseOps(s, known = {}) {
|
||||
const tk = s.trim().split(/\s+/).filter(Boolean);
|
||||
const ops = [];
|
||||
let i = 0;
|
||||
while (i < tk.length) {
|
||||
const op = tk[i] === "*" ? "×" : tk[i];
|
||||
let v = tk[i + 1];
|
||||
if (!OPS.includes(tk[i]) || v === undefined) return null;
|
||||
if (isNum(v)) {
|
||||
v = clean(v);
|
||||
// 값이 위 공식의 Q · QH 와 같으면 이름으로 이음(틀을 고치면 따라 바뀜)
|
||||
const hit = Object.entries(known).find(([n, val]) => /^Q/.test(n) && String(val) === v);
|
||||
if (hit) v = hit[0];
|
||||
} else if (!/^[A-Za-z]\w*$/.test(v)) return null;
|
||||
i += 2;
|
||||
const u = [];
|
||||
while (i < tk.length && !OPS.includes(tk[i])) u.push(tk[i++]);
|
||||
if (u.some((x) => /[()]/.test(x))) return null;
|
||||
ops.push({ op, v, u: u.join(" ") });
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
/** 「q = 0.7 ㎥ K = 0.55 f = 1. / 1.325 = 0.75」 → 줄 [{n, x, u}] */
|
||||
export function parseDefs(line) {
|
||||
const re = /(^|\s)([A-Za-z][A-Za-z0-9]*)\s*=\s/g;
|
||||
const starts = [];
|
||||
let m;
|
||||
while ((m = re.exec(line))) {
|
||||
const before = line.slice(starts.length ? starts.at(-1).body : 0, m.index).trim();
|
||||
if (starts.length && /[+\-×÷*/(]$/.test(before)) continue;
|
||||
starts.push({ n: m[2], at: m.index, body: m.index + m[0].length });
|
||||
}
|
||||
return starts.map((s, i) => {
|
||||
const seg = line.slice(s.body, starts[i + 1]?.at ?? line.length).trim();
|
||||
const parts = seg.split(/\s=\s/);
|
||||
const last = parts.at(-1).trim();
|
||||
const lm = last.match(/^(-?[\d,]*\.?\d+\.?)\s*(.*)$/);
|
||||
if (parts.length === 1)
|
||||
return { n: s.n, x: lm ? clean(lm[1]) : clean(last), u: lm ? lm[2] : "" };
|
||||
return { n: s.n, x: clean(parts[0]), u: lm ? lm[2] : "", got: lm ? num(lm[1]) : null };
|
||||
});
|
||||
}
|
||||
|
||||
/** 율 머리 「이름 ( 노무비의 1 % )」 + 다음 줄 「38,290.7 × 1. % = 382.9」 → 율 줄 */
|
||||
function rateHead(s, next) {
|
||||
const m = s.match(/^(.*?)\s*\(\s*(\S+?)의\s*([\d.]+)\s*%\s*\)\s*$/);
|
||||
if (!m || !/×\s*[\d.]+\s*%\s*=/.test(next ?? "")) return null;
|
||||
const bc = /노무/.test(m[2]) ? "l" : /경비/.test(m[2]) ? "e" : "m";
|
||||
return { k: "rate", name: m[1], of: m[2], baseCol: bc, rate: String(num(m[3])), col: "m" };
|
||||
}
|
||||
|
||||
/** 분수 세 줄(분자 · 「Q = ─ … = 23.39 ㎥ / hr」 · 분모) → 줄 하나 · 기호 반 · 값 반으로 나눔 */
|
||||
function parseFrac(top, mid, bot) {
|
||||
const mm = mid.match(/^([A-Za-z][A-Za-z0-9]*)\s*=\s*(.*)$/);
|
||||
if (!mm) return null;
|
||||
const parts = mm[2].split(/\s=\s/);
|
||||
const sym = parts[0];
|
||||
const groups = sym.match(/─(\s*─)*/g) ?? [];
|
||||
const half = (s) => {
|
||||
const t = s.trim().split(/\s+/);
|
||||
return t.length % 2 ? null : t.slice(0, t.length / 2);
|
||||
};
|
||||
const a = half(top);
|
||||
const b = half(bot);
|
||||
if (!a || !b || !groups.length) return null;
|
||||
if (groups.length > 1 && (a.length !== groups.length || b.length !== groups.length)) return null;
|
||||
let k = 0;
|
||||
const x = sym.replace(/─(\s*─)*/g, () => {
|
||||
const [na, nb] = groups.length > 1 ? [a[k], b[k]] : [a.join(" "), b.join(" ")];
|
||||
k++;
|
||||
return `(${na}) / (${nb})`;
|
||||
});
|
||||
const tail = parts
|
||||
.at(-1)
|
||||
.trim()
|
||||
.match(/^(-?[\d,]*\.?\d+\.?)\s*(.*)$/);
|
||||
return {
|
||||
n: mm[1],
|
||||
x: clean(x),
|
||||
u: tail && parts.length > 1 ? tail[2] : "",
|
||||
got: tail ? num(tail[1]) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePaste(text) {
|
||||
const lines = String(text)
|
||||
.replace(/\t/g, " ")
|
||||
.split(/\r?\n/)
|
||||
.map((s) => s.replace(/\s+/g, " ").trim());
|
||||
const doc = { name: "붙여넣은 단가산출", unit: "", items: [] };
|
||||
let expect = null;
|
||||
let item = null;
|
||||
let summary = false;
|
||||
let pendingBase = "";
|
||||
let skipRate = false;
|
||||
const known = {};
|
||||
const sums = { l: [], m: [], e: [] };
|
||||
const stat = { read: 0, text: 0 };
|
||||
const cur = () => item ?? ((item = { title: "", lines: [] }), doc.items.push(item), item);
|
||||
const add = (ln) => (cur().lines.push(ln), ln.k === "text" ? stat.text++ : stat.read++);
|
||||
const formula = () => {
|
||||
const last = cur().lines.at(-1);
|
||||
if (last?.k === "formula") return last;
|
||||
const f = { k: "formula", tpl: null, rows: [] };
|
||||
add(f);
|
||||
return f;
|
||||
};
|
||||
const addRow = (r) => {
|
||||
formula().rows.push({ n: r.n, x: r.x, u: r.u });
|
||||
if (r.got != null) known[r.n] = String(r.got);
|
||||
else if (isNum(r.x)) known[r.n] = r.x;
|
||||
};
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const s = lines[i];
|
||||
if (!s || /^단가산출$|^산 ?출 ?근 ?거/.test(s)) continue;
|
||||
let m = s.match(/^(\d+)\.(\S.*?)\[(.+?)\]\s*([\d,]+)?\s*([\d,]+)?\s*([\d,]+)?\s*([\d,]+)?\s*$/);
|
||||
if (m && !doc.items.length) {
|
||||
doc.name = m[2].trim();
|
||||
doc.unit = m[3];
|
||||
if (m[4])
|
||||
expect = { sum: num(m[4]), l: num(m[5] ?? 0), m: num(m[6] ?? 0), e: num(m[7] ?? 0) };
|
||||
continue;
|
||||
}
|
||||
// 분수 세 줄
|
||||
if (lines[i + 1]?.includes("─")) {
|
||||
const r = parseFrac(s, lines[i + 1], lines[i + 2] ?? "");
|
||||
if (r) {
|
||||
addRow(r);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
for (const t of [s, lines[i + 1], lines[i + 2] ?? ""]) add({ k: "text", text: t });
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if ((m = s.match(/^(\d+)\s*\.\s+(.*)$/)) || (m = s.match(/^([가-하])\s*\.\s+(.*)$/))) {
|
||||
summary = /^(합계|소계)$/.test(m[2].replace(/\s/g, ""));
|
||||
if (summary) continue;
|
||||
item = { title: m[2], lines: [], ...(/[가-하]/.test(m[1]) ? { sub: true } : {}) };
|
||||
doc.items.push(item);
|
||||
const rm = rateHead(m[2], lines[i + 1]);
|
||||
if (rm) {
|
||||
add({ ...rm, name: "", scope: "all", baseName: "" });
|
||||
pendingBase = "";
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const key = s.split(":")[0].replace(/\s/g, "");
|
||||
if (summary && /^(노무비|재료비|경비|계)$/.test(key)) {
|
||||
if (KEY[key]) {
|
||||
const nums =
|
||||
s
|
||||
.slice(s.indexOf(":") + 1)
|
||||
.split("=")[0]
|
||||
.match(/[\d,]+\.?\d*/g) ?? [];
|
||||
sums[KEY[key]].push(...nums.map(num));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (/^소계$/.test(key)) continue;
|
||||
if (s.includes(":") && /소계$/.test(key)) {
|
||||
pendingBase = key.replace(/소계$/, "");
|
||||
continue;
|
||||
}
|
||||
// 율 줄 머리 「이름 ( 노무비의 1 % )」 + 다음 줄 「38,290.7 × 1. % = 382.9」
|
||||
const rm = rateHead(s, lines[i + 1]);
|
||||
if (rm) {
|
||||
add({ ...rm, baseName: pendingBase, scope: "item" });
|
||||
pendingBase = "";
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// 「q = 0.7 K = 0.55」 값 · 식 줄
|
||||
if (/^[A-Za-z][A-Za-z0-9]*\s*=\s/.test(s) && !s.includes(":")) {
|
||||
parseDefs(s).forEach(addRow);
|
||||
continue;
|
||||
}
|
||||
// 「노무비 : 59,020 ÷ 23.39 = 2,523.3」 셋 → 기계 · 참조 줄
|
||||
if (KEY[key] && s.includes(":")) {
|
||||
const rhs = s
|
||||
.slice(s.indexOf(":") + 1)
|
||||
.split("=")[0]
|
||||
.trim();
|
||||
const pm = rhs.match(/^([\d,]+\.?)\s+(.*)$/);
|
||||
const ops = pm && parseOps(pm[2], known);
|
||||
if (!pm || !ops) {
|
||||
if (rhs) add({ k: "text", text: s });
|
||||
continue;
|
||||
}
|
||||
const c = KEY[key];
|
||||
let last = cur().lines.at(-1);
|
||||
if (!(
|
||||
last &&
|
||||
(last.k === "mach" || last.k === "ref") &&
|
||||
!(c in last.res) &&
|
||||
last.res._open
|
||||
)) {
|
||||
const ref = (item?.title ?? "").match(/단산\s*(\d+)\s*참조/);
|
||||
last = ref
|
||||
? { k: "ref", res: { no: Number(ref[1]), name: item.title, _open: true }, ops }
|
||||
: { k: "mach", res: { name: item?.title ?? "", _open: true }, ops };
|
||||
add(last);
|
||||
}
|
||||
last.res[c] = num(pm[1]);
|
||||
if (JSON.stringify(ops) !== JSON.stringify(last.ops)) (last.cops ??= {})[c] = ops;
|
||||
continue;
|
||||
}
|
||||
// 「치즐 ( 223,000. × 0.006 ) ÷ 5.」 → 괄호 걷음(곱나눔 차례 그대로라 값 같음)
|
||||
const s2 = s.replace(/\(\s*([\d,]+\.?\s+[×÷][^()]*?)\s*\)/, "$1");
|
||||
// 「보통인부 : 172,068 ÷ 8. ÷ 23.39 = 919.5」 → 인력
|
||||
if ((m = s2.match(/^([^:]+?)\s*:\s*([\d,]+\.?)\s+([×÷/*].*?)\s*=\s*[\d,.]+/))) {
|
||||
const ops = parseOps(m[3], known);
|
||||
if (ops) {
|
||||
add({ k: "labor", res: { name: m[1], price: num(m[2]) }, ops });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// 「합판(내수),대전 (…) 11,455. × 1.03 ㎡ × 38. % = 4,483.4」 → 재료
|
||||
if ((m = s2.match(/^(.*\S)\s+([\d,]+\.?)\s+([×÷/*].*?)\s*=\s*[\d,.]+/)) && !s.includes(":")) {
|
||||
const ops = parseOps(m[3], known);
|
||||
if (ops) {
|
||||
add({ k: "mat", col: "m", res: { name: m[1], price: num(m[2]) }, ops });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
add({ k: "text", text: s });
|
||||
}
|
||||
for (const it of doc.items) for (const ln of it.lines) if (ln.res) delete ln.res._open;
|
||||
// 율 줄이 든 비목 = 합계 줄에 그 값이 놓인 칸
|
||||
const res = compute(doc);
|
||||
doc.items.forEach((it, ii) =>
|
||||
it.lines.forEach((ln, li) => {
|
||||
if (ln.k !== "rate") return;
|
||||
const v = Object.values(res.items[ii].lines[li].cols)[0];
|
||||
const col =
|
||||
["m", "e", "l"].find((c) => sums[c].includes(v)) ??
|
||||
["m", "e", "l"].find((c) => sums[c].includes(Math.floor(v)));
|
||||
if (col) ln.col = col;
|
||||
}),
|
||||
);
|
||||
return { doc, expect, stat };
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// 줄 쌓기 단가산출 시험 — 산출근거 출력(경쟁사 「단가산출」 시트 꼴 · 분수는 CSS 로 조판)
|
||||
import { COLS, COL_NAME, parse, varsIn } from "./calc.js";
|
||||
|
||||
export const esc = (s) =>
|
||||
String(s ?? "").replace(
|
||||
/[&<>"]/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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>`;
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// 줄 쌓기 단가산출 시험 — 견본 자원 · 공식 틀 · 견본 표 · 견본 단가산출(거창 산불 출력 「단가산출」 시트 1 · 41 · 15 번)
|
||||
|
||||
export const LABOR = [
|
||||
{ name: "보통인부", price: 172068 },
|
||||
{ name: "형틀목공", price: 275790 },
|
||||
{ name: "석공", price: 268908 },
|
||||
];
|
||||
|
||||
export const MATERIAL = [
|
||||
{ name: "합판(내수),대전 (12t×1220×2440mm)", price: 11455 },
|
||||
{ name: "외송각재,대전 (30×30×3600mm)", price: 586824 },
|
||||
{ name: "치즐(0.7㎥)", price: 223000 },
|
||||
];
|
||||
|
||||
/** 기계 = 시간당 기계경비 노무 · 재료 · 경비 */
|
||||
export const MACHINE = [
|
||||
{ name: "굴삭기(무한궤도) (0.7㎥)", l: 59020, m: 19473, e: 24554 },
|
||||
{ name: "굴삭기(무한궤도) (0.6㎥+부착용집게)", l: 59020, m: 16280, e: 29209 },
|
||||
{ name: "덤프트럭 (15Ton)", l: 59020, m: 30192, e: 20659 },
|
||||
];
|
||||
|
||||
/** 다른 단가산출 = 그 단산의 노무 · 재료 · 경비 */
|
||||
export const SANGEUN = [
|
||||
{ no: 13, name: "뒷채움돌 기초잡석 채집운반(Φ150m/m) [㎡]", l: 9889, m: 2262, e: 3858 },
|
||||
{ no: 28, name: "기초다짐및뒷채움(소형장비사용) [㎥]", l: 11206, m: 1077, e: 1259 },
|
||||
{ no: 1, name: "측구터파기(토사) [㎥]", l: 3442, m: 832, e: 1049 },
|
||||
];
|
||||
|
||||
const row = (n, x, u = "", d) => (d === undefined ? { n, x, u } : { n, x, u, d });
|
||||
|
||||
/** 공식 틀 — 「이름 = 값」 · 「이름 = 식」 줄 묶음 */
|
||||
export const TEMPLATES = [
|
||||
{
|
||||
id: "excavator",
|
||||
name: "굴삭기 Q (품셈 8-2-3)",
|
||||
rows: [
|
||||
row("q", "0.7", "㎥"),
|
||||
row("K", "0.55"),
|
||||
row("f", "1 / 1.325"),
|
||||
row("E", "0.45"),
|
||||
row("Cm", "20", "sec ( 135 ˚ )"),
|
||||
row("Q", "3600 × q × K × f × E / Cm", "㎥ / hr"),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "dump",
|
||||
name: "덤프 Q (q · K 는 위 굴삭기 것)",
|
||||
rows: [
|
||||
row("L", "0.1", "km"),
|
||||
row("V1", "7", "km / hr"),
|
||||
row("V2", "8", "km / hr"),
|
||||
row("T", "15", "ton"),
|
||||
row("rt", "1.9", "ton / ㎥"),
|
||||
row("E", "0.9"),
|
||||
row("qt", "T ÷ rt", "㎥"),
|
||||
row("f", "0.89"),
|
||||
row("Cms", "22"),
|
||||
row("Es", "0.35"),
|
||||
row("n", "qt / (q × K)"),
|
||||
row("t1", "Cms × n / (60 × Es)"),
|
||||
row("t2", "(L / V1 + L / V2) × 60", "분 ( 왕복 )"),
|
||||
row("t3", "1.1", "분 ( 적하 )"),
|
||||
row("t4", "0.7", "분 ( 대기 )"),
|
||||
row("Cm", "t1 + t2 + t3 + t4", "분"),
|
||||
row("QH", "(Cm - t1) / Cm"),
|
||||
row("Q", "60 × qt × f × E / Cm", "㎥ / hr"),
|
||||
],
|
||||
},
|
||||
{ id: "directQ", name: "직접 Q", rows: [row("Q", "0.35")] },
|
||||
];
|
||||
|
||||
/** 견본 표 하나 — [표에서 고르기] (표준품셈 8-2-3 굴착기 2. 작업효율 E) */
|
||||
export const TABLE_E = {
|
||||
title: "굴착기 작업효율 E (표준품셈 8-2-3)",
|
||||
head: ["토질", "자연 양호", "자연 보통", "자연 불량", "흐트 양호", "흐트 보통", "흐트 불량"],
|
||||
rows: [
|
||||
["모래, 사질토", 0.85, 0.7, 0.55, 0.9, 0.75, 0.6],
|
||||
["자갈섞인 흙, 점성토", 0.75, 0.6, 0.45, 0.8, 0.65, 0.5],
|
||||
["파쇄암", null, null, null, null, 0.45, 0.35],
|
||||
],
|
||||
};
|
||||
|
||||
const op = (o, v, u = "") => ({ op: o, v, u });
|
||||
const tplRows = (id) => structuredClone(TEMPLATES.find((t) => t.id === id).rows);
|
||||
|
||||
export const SAMPLES = [
|
||||
{
|
||||
key: "s1",
|
||||
label: "① 측구터파기(토사)",
|
||||
expect: { l: 3442, m: 832, e: 1049, sum: 5323 },
|
||||
doc: {
|
||||
name: "측구터파기(토사)",
|
||||
unit: "㎥",
|
||||
items: [
|
||||
{
|
||||
title: "기 계 ( 100 % ) : 굴삭기(무한궤도) (0.7㎥)",
|
||||
lines: [
|
||||
{ k: "formula", tpl: "excavator", rows: tplRows("excavator") },
|
||||
{ k: "mach", res: { ...MACHINE[0] }, ops: [op("÷", "Q")] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "보조인부 ( 1 일당 1 인 )",
|
||||
lines: [{ k: "labor", res: { ...LABOR[0] }, ops: [op("÷", "8"), op("÷", "Q")] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "s41",
|
||||
label: "② 합판거푸집(4회)",
|
||||
expect: { l: 38290, m: 14506, e: 0, sum: 52796 },
|
||||
doc: {
|
||||
name: "합판거푸집(4회)",
|
||||
unit: "㎡",
|
||||
items: [
|
||||
{
|
||||
title: "",
|
||||
lines: [
|
||||
{ k: "text", text: "< 토목품셈 6-3-1 합판거푸집(2022개정) >" },
|
||||
{ k: "text", text: "※ 사용횟수 4 회 : 보통" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "재료비",
|
||||
lines: [
|
||||
{
|
||||
k: "mat",
|
||||
col: "m",
|
||||
res: { ...MATERIAL[0] },
|
||||
ops: [op("×", "1.03", "㎡"), op("×", "38", "%")],
|
||||
},
|
||||
{
|
||||
k: "mat",
|
||||
col: "m",
|
||||
res: { ...MATERIAL[1] },
|
||||
ops: [op("×", "0.038", "㎥"), op("×", "38", "%")],
|
||||
},
|
||||
{
|
||||
k: "rate",
|
||||
name: "소모자재 ( 박리재등 )",
|
||||
of: "주자재비",
|
||||
baseName: "주자재",
|
||||
baseCol: "m",
|
||||
scope: "item",
|
||||
rate: "9",
|
||||
col: "m",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "인건비 ( 24 년 품셈 개정 )",
|
||||
lines: [
|
||||
{ k: "labor", res: { ...LABOR[1] }, ops: [op("×", "5", "인"), op("/", "45", "㎡")] },
|
||||
{ k: "labor", res: { ...LABOR[0] }, ops: [op("×", "2", "인"), op("/", "45", "㎡")] },
|
||||
{
|
||||
k: "rate",
|
||||
name: "공구손료 및 경장비 기계경비",
|
||||
of: "노무비",
|
||||
baseName: "인부",
|
||||
baseCol: "l",
|
||||
scope: "item",
|
||||
rate: "1",
|
||||
col: "m",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "s15",
|
||||
label: "③ 전석 메쌓기",
|
||||
expect: { l: 74880, m: 7029, e: 12306, sum: 94215 },
|
||||
doc: {
|
||||
name: "전석 메쌓기",
|
||||
unit: "㎡",
|
||||
items: [
|
||||
{ title: "전석 구입운반 : 별산", lines: [] },
|
||||
{
|
||||
title: "뒷채움돌 운반 및 채집 : 뒷채움돌기초잡석채집운반 (Φ150m/m) [㎡] * 단산 13 참조 *",
|
||||
lines: [
|
||||
{ k: "formula", tpl: "directQ", rows: [row("Q", "0.35")] },
|
||||
{ k: "ref", res: { ...SANGEUN[0] }, ops: [op("×", "Q")] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "뒤채움돌 다짐 기초다짐및뒷채움 (소형장비사용) [㎥] * 단산 28 참조 *",
|
||||
lines: [
|
||||
{ k: "formula", tpl: "directQ", rows: [row("Q", "0.35")] },
|
||||
{ k: "ref", res: { ...SANGEUN[1] }, ops: [op("×", "Q")] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "전석쌓기 기계: 굴삭기(무한궤도) (0.6㎥+부착용집게)",
|
||||
lines: [
|
||||
{ k: "formula", tpl: "directQ", rows: [row("Q", "0.36", "㎥ / hr")] },
|
||||
{ k: "mach", res: { ...MACHINE[1] }, ops: [op("×", "Q")] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "전석쌓기 인부",
|
||||
lines: [
|
||||
{ k: "labor", res: { ...LABOR[2] }, ops: [op("×", "0.14", "인")] },
|
||||
{ k: "labor", res: { ...LABOR[0] }, ops: [op("×", "0.05", "인")] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "new",
|
||||
label: "④ 새 공식",
|
||||
expect: null,
|
||||
doc: {
|
||||
name: "새 단가산출",
|
||||
unit: "",
|
||||
items: [{ title: "", lines: [{ k: "formula", tpl: null, rows: [row("", "")] }] }],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,306 @@
|
||||
/* 시험 페이지 전용 — 본 프로그램 부품 상자 밖(폴더째 지움) */
|
||||
:root {
|
||||
--bg: #f6f7f9;
|
||||
--panel: #fff;
|
||||
--line: #d5d9e0;
|
||||
--text: #1d232b;
|
||||
--muted: #6b7380;
|
||||
--accent: #2f6fde;
|
||||
--bad: #d23b3b;
|
||||
--ok: #1f8a4c;
|
||||
--chip: #eef2f8;
|
||||
color-scheme: light dark;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #15181d;
|
||||
--panel: #1e2228;
|
||||
--line: #343a44;
|
||||
--text: #e3e7ec;
|
||||
--muted: #9aa3ae;
|
||||
--accent: #6c9ef0;
|
||||
--bad: #ff6b6b;
|
||||
--ok: #4cc27f;
|
||||
--chip: #2a3039;
|
||||
}
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font:
|
||||
13px/1.45 "Malgun Gothic",
|
||||
system-ui,
|
||||
sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
header,
|
||||
footer {
|
||||
padding: 6px 10px;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--line);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
footer {
|
||||
border-top: 1px solid var(--line);
|
||||
border-bottom: 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: 15px;
|
||||
margin: 0 8px 0 0;
|
||||
}
|
||||
.hint,
|
||||
#msg,
|
||||
#pasteMsg,
|
||||
.note,
|
||||
i {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
main {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(420px, 46%) 1fr;
|
||||
min-height: 0;
|
||||
}
|
||||
#edit,
|
||||
#outWrap {
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
#edit {
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
button,
|
||||
select,
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
button {
|
||||
cursor: pointer;
|
||||
background: var(--chip);
|
||||
}
|
||||
button:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
input.wide {
|
||||
width: 260px;
|
||||
}
|
||||
input.num {
|
||||
width: 80px;
|
||||
text-align: right;
|
||||
}
|
||||
input.s {
|
||||
width: 50px;
|
||||
}
|
||||
input.s2 {
|
||||
width: 90px;
|
||||
}
|
||||
input.full,
|
||||
input.title {
|
||||
width: 100%;
|
||||
}
|
||||
input.title {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
}
|
||||
input.v {
|
||||
width: 60px;
|
||||
}
|
||||
input.u {
|
||||
width: 50px;
|
||||
}
|
||||
input.name {
|
||||
width: 60px;
|
||||
}
|
||||
input.expr {
|
||||
width: 220px;
|
||||
font-family: Consolas, monospace;
|
||||
}
|
||||
.dochead,
|
||||
.row,
|
||||
.ih,
|
||||
.lh,
|
||||
.ops,
|
||||
.frow,
|
||||
.fhead {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
margin: 2px 0;
|
||||
}
|
||||
.item {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
padding: 6px;
|
||||
margin: 6px 0;
|
||||
}
|
||||
.line {
|
||||
border-left: 3px solid var(--accent);
|
||||
background: var(--bg);
|
||||
padding: 4px 6px;
|
||||
margin: 4px 0 4px 8px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.line.k-text {
|
||||
border-left-color: var(--muted);
|
||||
}
|
||||
.line.k-formula {
|
||||
border-left-color: #a46bd8;
|
||||
}
|
||||
.line.k-rate {
|
||||
border-left-color: #d8973b;
|
||||
}
|
||||
.grip {
|
||||
cursor: grab;
|
||||
padding: 0 4px;
|
||||
user-select: none;
|
||||
}
|
||||
.kind {
|
||||
font-weight: 600;
|
||||
}
|
||||
.lh button {
|
||||
margin-left: auto;
|
||||
}
|
||||
.op {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
padding: 1px 3px;
|
||||
background: var(--chip);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.tpl {
|
||||
font-weight: 600;
|
||||
color: #a46bd8;
|
||||
}
|
||||
.val {
|
||||
min-width: 50px;
|
||||
font-family: Consolas, monospace;
|
||||
}
|
||||
.src {
|
||||
background: var(--ok);
|
||||
color: #fff;
|
||||
border-radius: 3px;
|
||||
padding: 0 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.bad,
|
||||
input.bad {
|
||||
color: var(--bad);
|
||||
border-color: var(--bad);
|
||||
}
|
||||
.ok {
|
||||
color: var(--ok);
|
||||
}
|
||||
table.out {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
background: var(--panel);
|
||||
}
|
||||
.out th,
|
||||
.out td {
|
||||
border: 1px solid var(--line);
|
||||
padding: 2px 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.out th {
|
||||
background: var(--chip);
|
||||
}
|
||||
.out td.h {
|
||||
min-width: 380px;
|
||||
}
|
||||
.out td.n {
|
||||
text-align: right;
|
||||
font-family: Consolas, monospace;
|
||||
width: 90px;
|
||||
}
|
||||
.out tr.head td,
|
||||
.out tr.item td.h {
|
||||
font-weight: 600;
|
||||
}
|
||||
.out tr.item td.h {
|
||||
padding-top: 6px;
|
||||
}
|
||||
.out tr.subsum td,
|
||||
.out tr.total td {
|
||||
background: var(--chip);
|
||||
}
|
||||
.gap {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
}
|
||||
.fr {
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
vertical-align: middle;
|
||||
text-align: center;
|
||||
margin: 0 2px;
|
||||
}
|
||||
.fr > span:first-child {
|
||||
border-bottom: 1px solid currentColor;
|
||||
padding: 0 3px;
|
||||
}
|
||||
.fr > span:last-child {
|
||||
padding: 0 3px;
|
||||
}
|
||||
#tot {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.box {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 2px 10px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
}
|
||||
.box b {
|
||||
font-size: 15px;
|
||||
}
|
||||
.box.sum {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
details {
|
||||
width: 100%;
|
||||
}
|
||||
textarea {
|
||||
width: 100%;
|
||||
font-family: Consolas, monospace;
|
||||
}
|
||||
dialog {
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
dialog table {
|
||||
border-collapse: collapse;
|
||||
margin: 6px 0;
|
||||
}
|
||||
dialog th,
|
||||
dialog td {
|
||||
border: 1px solid var(--line);
|
||||
padding: 2px 6px;
|
||||
}
|
||||
Reference in New Issue
Block a user