feat(804-2): 단가산출 「줄 쌓기」 새 탭 — M01 단가산출 기초 · M02 단가산출 · 기계 시간당 경비 수식 펼침 · KaTeX

- 공용 화면 A00_Common/line_stack_* — 위 옵션 · 왼쪽 줄 쌓기 편집 · 오른쪽 산출근거(경쟁사 시트 꼴) · 맨 아래 합계 상자
- 모델 common_util_line_stack.ts — 수식 문서 ↔ 줄 쌓기 한 벌 왕복(차림 = 문서 칸 `쌓기` · 기본과 다를 때만)
- 기계 · 단가산출 줄 단가 수 누름 = 그 문서를 같은 계산기로 셈해 펼침(손료 식 · 운전경비 · 노 · 재 · 경 · 계)
- 공식 묶음 · 틀 풀기(단가산출 결과 → 이 문서 식) · 내 틀(이 브라우저) · 「내 틀 (… 에서 변경)」
- 수식 탭과 같은 길 · 같은 캐시 · 탭 바꾸면 다시 읽음 · [수식 저장] = 서버 정본 셈
- KaTeX(MIT) — vite 미리 묶기에서 뺌(rolldown 이 글자 규칙 서로게이트 범위를 깨뜨림)
- 시험 test_804_2 6(왕복 1,518 · 금액 · 기계 펼침 = hourly · 틀 풀기 · 저장 · 이음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-08 00:34:29 +09:00
co-authored by Claude Opus 5.5
parent 26bd46041e
commit c11baa441c
19 changed files with 2657 additions and 14 deletions
+197
View File
@@ -0,0 +1,197 @@
/* 줄 쌓기 탭(804-2) — 위 옵션 · 가운데 왼쪽 편집 | 오른쪽 산출근거 · 맨 아래 합계 상자 · 색은 공통 값만 */
.lstk {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
min-width: 0;
container-type: inline-size;
}
.lstk__head {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.lstk__bar,
.lstk__badges,
.lstk__tools {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--spacing-8);
}
.lstk__areas {
display: grid;
grid-template-columns: minmax(0, 5fr) minmax(0, 6fr);
gap: var(--spacing-12);
align-items: start;
}
.lstk:not([data-edit="1"]) .lstk__areas {
grid-template-columns: minmax(0, 1fr);
}
.lstk:not([data-edit="1"]) .lstk__left {
display: none;
}
/* 탭 폭이 좁으면(1366 화면 등) 편집 위 · 산출근거 아래 */
@container (max-width: 1240px) {
.lstk__areas {
grid-template-columns: minmax(0, 1fr);
}
}
/* ── 왼쪽 편집 ── */
.lstk-edit {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.lstk-item {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.lstk-ihead,
.lstk-row,
.lstk-fhead,
.lstk-frow,
.lstk-find,
.lstk-ltools {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--spacing-4) var(--spacing-8);
min-width: 0;
}
.lstk-frow {
align-items: center;
}
.lstk-line {
position: relative;
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-8);
padding-inline-end: 120px;
border-radius: var(--radius-sm);
background: var(--color-surface-sunken);
}
.lstk-ltools {
position: absolute;
inset-block-start: var(--spacing-4);
inset-inline-end: var(--spacing-4);
gap: 0;
}
.lstk-kind {
font-size: var(--text-caption);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
}
.lstk-fhead[data-tpl="changed"] .lstk-kind {
color: var(--color-accent);
}
.lstk-f-s {
width: 6em;
}
.lstk-f-m {
width: 9em;
}
.lstk-f-l {
flex: 1 1 12em;
min-width: 10em;
}
.lstk-sym {
font-weight: var(--font-weight-medium);
}
.lstk-val,
.lstk-unit,
.lstk-tag,
.lstk-hint,
.lstk-link {
font-size: var(--text-body-sm);
color: var(--color-text-secondary);
}
.lstk-link {
color: var(--color-text-body);
}
.lstk-tag {
padding: 0 var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.lstk-empty {
font-size: var(--text-body-sm);
color: var(--color-text-muted);
}
.lstk-miss,
.lstk-tex .lstk-miss {
color: var(--color-danger);
}
/* ── 오른쪽 산출근거 ── */
.lstk-out .lstk-h {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0 var(--spacing-4);
padding-inline-start: calc(var(--lstk-depth, 0) * var(--spacing-24));
white-space: normal;
}
.lstk-out tr.head,
.lstk-out tr.item,
.lstk-out tr.total {
font-weight: var(--font-weight-medium);
}
.lstk-out tr.subsum,
.lstk-out tr.total {
background: var(--color-surface-sunken);
}
.lstk-out tr.is-sub .lstk-h {
color: var(--color-text-secondary);
}
.lstk-out tr.subhead .lstk-h {
color: var(--color-accent);
}
.lstk-gap {
display: inline-block;
width: var(--spacing-24);
}
.lstk-pack {
display: inline-flex;
align-items: center;
gap: var(--spacing-4);
}
.lstk-tex .katex {
font-size: 1.05em;
}
+377
View File
@@ -0,0 +1,377 @@
/* =============================================================================
* line_stack_edit.ts
* 줄 쌓기 왼쪽 편집(PLAN 804-2 · 요구 670~677) — 항목(제목 · 가나다 · [+ 줄] · 지움) 안 줄 카드(위 · 아래 · 지움)
* 줄 = 인력 · 재료 · 기계 · 다른 단가산출 · 율 · 식(금액 줄 — 이름 · 단위 · 비목 · 수량 식 · 단가 고르기 또는 식)
* · 공식 묶음(`line_stack_rows.ts`) · 글 줄
* 고침 = 부른 쪽 `change(fn)` — fn 이 문서 · 차림을 고치면 부른 쪽이 `docOf` 로 굳히고 미리 셈 · 다시 그림(입력은 change 때)
* 자원 찾기 = 수식 탭과 같은 길(`/links` 요소 인력 · 재료 · 단가산출) · 기계 = 시간당 기계경비 단가산출 + 기종(그 문서 기계 목록)
* ========================================================================== */
import { createButton } from "@ui/ui_template_button";
import { createCheckbox } from "@ui/ui_template_checkbox";
import { el } from "@ui/ui_template_elements";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { varId, type FormulaDoc, type MoneyLine } from "../common_util/common_util_formula_doc";
import type { DocAnswer } from "../common_util/common_util_formula_doc_calc";
import {
dropSyms,
freshId,
kindOf,
newVar,
ownedOf,
type MoneyKind,
type StackDoc,
type StackItem,
} from "../common_util/common_util_line_stack";
import type { Pick } from "./formula_doc_defs";
import { buildBlock, insertTpl, type Tpl } from "./line_stack_rows";
import { lt, type LsKey } from "./line_stack_text";
export interface EditHooks {
doc: StackDoc;
answer: DocAnswer;
items: StackItem[];
change: (fn: (d: StackDoc, items: StackItem[]) => void) => void;
ask: (kind: string, words: string, group?: string) => Promise<{ 후보: Pick[] }>;
subDoc: (key: string) => FormulaDoc | null;
name: (key: string) => string;
/** 공식 묶음 펼친 자리(`항목.줄`) — 화면 상태만 */
open: Set<string>;
templates: () => Tpl[];
saveTpl: (tpl: Tpl) => void;
}
const field = (
label: string,
value: unknown,
set: (t: string) => void,
size: "s" | "m" | "l" = "m",
): HTMLElement => {
const f = createInputField({
label,
value: value === undefined || value === null ? "" : String(value),
compact: true,
size: 24,
});
f.root.classList.add(`lstk-f-${size}`);
f.input.addEventListener("change", () => set(f.input.value));
return f.root;
};
const btn = (label: string, onClick: () => void, variant: "ghost" | "danger-ghost" = "ghost") =>
createButton({ label, variant, size: 24, onClick });
/** 자원 찾기 — 찾는 말 → 후보 고르기 */
function finder(
h: EditHooks,
kind: string,
group: string | undefined,
seed: string,
onPick: (p: Pick) => void,
): HTMLElement {
const words = createInputField({
placeholder: lt("Find"),
value: seed,
compact: true,
size: 24,
type: "search",
});
const pick = createSelectField({
options: [{ value: "", text: lt("Pick") }],
compact: true,
size: 24,
ariaLabel: lt("Pick"),
});
let found: Pick[] = [];
const run = (): void =>
void h.ask(kind, words.input.value, group).then((got) => {
found = got.후보 ?? [];
pick.setOptions([
{ value: "", text: found.length ? lt("Pick") : lt("None_Found") },
...found.map((p, k) => ({
value: String(k),
text: [p.이름, p.규격].filter(Boolean).join(" "),
})),
]);
});
words.input.addEventListener("change", run);
pick.select.addEventListener("change", () => {
const p = found[Number(pick.select.value)];
if (pick.select.value !== "" && p) onPick(p);
});
return el("div", { className: "lstk-find", children: [words.root, pick.root] });
}
const KIND_KEY: Record<MoneyKind, LsKey> = {
인력: "K_인력",
재료: "K_재료",
기계: "K_기계",
단가산출: "K_단가산출",
율: "K_율",
};
/** 금액 줄 몸 */
function moneyBody(h: EditHooks, i: number): HTMLElement[] {
const d = h.doc;
const line = (d.금액 ?? [])[i];
const kind = kindOf(d, i, h.subDoc);
const own = ownedOf(d).get(i);
const v = own ? d.변수?.find((x) => varId(x) === own) : undefined;
const put = (fn: (l: MoneyLine & { 규격?: string }) => void): void =>
h.change((doc) => fn(doc.금액![i]));
const setVar = (
fn: (link: NonNullable<typeof v>["연결"] & object, l: MoneyLine & { 규격?: string }) => void,
): void =>
h.change((doc) => {
const x = doc.변수!.find((y) => varId(y) === own)!;
x.연결 ??= {};
fn(x.연결, doc.금액![i]);
});
const named = (p: Pick, l: MoneyLine & { 규격?: string }): void => {
l.이름 = p.이름 ?? l.이름;
if (p.규격) l.규격 = p.규격;
else delete l.규격;
};
const head = el("div", {
className: "lstk-row",
children: [
field(lt("Name"), line.이름, (t) => put((l) => (l.이름 = t)), "l"),
field(lt("Unit"), line.단위, (t) => put((l) => (l.단위 = t)), "s"),
...(line.비목 === "묶음"
? []
: [
createSelectField({
label: lt("Cost"),
options: ["노무비", "재료비", "경비"].map((x) => ({ value: x, text: x })),
value: line.비목 ?? "노무비",
compact: true,
size: 24,
onChange: (x) => put((l) => (l.비목 = x)),
}).root,
]),
],
});
const qty = field(
lt("Qty"),
line.수량 ?? "",
(t) => put((l) => (t.trim() ? (l.수량 = t.trim()) : delete l.수량)),
"l",
);
const price: HTMLElement[] = [];
if (own && v) {
const link = v.연결 ?? {};
const label = link.키
? link.갈래 === "단가산출"
? (h.subDoc(link.키)?.이름 ?? h.name(link.키))
: h.name(link.키)
: "";
price.push(el("span", { className: "lstk-link", text: label || "—" }));
if (link.갈래 === "요소")
price.push(
finder(h, "요소", kind === "인력" ? "인력" : "재료", "", (p) =>
setVar((k, l) => {
for (const x of Object.keys(k)) delete (k as Record<string, unknown>)[x];
Object.assign(k, { 갈래: "요소", 키: p.키 });
named(p, l);
}),
),
);
else {
const sub = link.키 ? h.subDoc(link.키) : null;
const codes = sub?.변수?.find((x) => x.열쇠 === "기계")?.연결;
if (kind === "기계" && codes?.목록) {
const now = String(link.입력?.기계 ?? `'${codes.값 ?? ""}'`).replace(/^'|'$/g, "");
price.push(
createSelectField({
label: lt("Machine"),
options: codes.목록.map((x) => ({ value: String(x), text: String(x) })),
value: now,
compact: true,
size: 24,
onChange: (x) => setVar((k) => (k.입력 = { ...(k.입력 ?? {}), 기계: `'${x}'` })),
}).root,
);
}
price.push(
finder(
h,
"단가산출",
undefined,
!link.키 && own.startsWith("P_") ? "시간당 기계경비" : "",
(p) =>
setVar((k, l) => {
const keep = k.키 === p.키 ? (k.입력 ?? {}) : {};
for (const x of Object.keys(k)) delete (k as Record<string, unknown>)[x];
Object.assign(k, { 갈래: "단가산출", 키: p.키, 입력: keep });
if (!l.이름) named(p, l);
}),
),
);
}
} else price.push(field(lt("Price"), line.단가, (t) => put((l) => (l.단가 = t.trim())), "l"));
const why = (h.answer.줄?.[i] as { 까닭?: string } | undefined)?.까닭;
return [
el("span", { className: "lstk-kind", text: lt(KIND_KEY[kind]) }),
head,
el("div", { className: "lstk-row", children: [qty, el("span", { text: "×" }), ...price] }),
...(why ? [el("div", { className: "lstk-miss", text: why })] : []),
];
}
/** [+ 줄] 고르개 값 → 새 줄 */
function addLine(h: EditHooks, at: number, kind: string): void {
if (kind.startsWith("tpl:")) {
const t = h.templates().find((x) => x.id === kind.slice(4));
if (t) h.change((d, items) => items[at].줄.push(insertTpl(d, t)));
return;
}
h.change((d, items) => {
const it = items[at];
const n = (d.금액 ??= []).length;
const money = (line: MoneyLine, id?: string, link?: Record<string, unknown>): void => {
if (id) (d.변수 ??= []).push(newVar(id, link as never));
d.금액!.push(line);
it.줄.push({ 꼴: "금액", i: n });
};
if (kind === "인력" || kind === "재료") {
const id = freshId(d, kind === "인력" ? "w" : "P", "새");
money(
{
이름: "",
단위: kind === "인력" ? "인" : "",
비목: kind === "인력" ? "노무비" : "재료비",
수량: "1",
단가: id,
},
id,
{ 갈래: "요소" },
);
} else if (kind === "기계" || kind === "단가산출") {
const id = freshId(d, kind === "기계" ? "P" : "B", "새");
money(
{ 이름: "", 단위: kind === "기계" ? "hr" : "", 비목: "묶음", 수량: "1", 단가: id },
id,
{ 갈래: "단가산출", 입력: {} },
);
} else if (kind === "율")
money({ 이름: "공구손료 및 경장비", 단위: "식", 비목: "경비", 단가: "노무비 × 3 %" });
else if (kind === "공식") {
const id = freshId(d, "x");
(d.변수 ??= []).push(newVar(id, { 갈래: "숫자", 값: 0 }));
it.줄.push({ 꼴: "공식", 기호: [id] });
h.open.add(`${at}.${it.줄.length - 1}`);
} else if (kind === "틀") {
const id = freshId(d, "Q", "틀");
(d.변수 ??= []).push(newVar(id, { 갈래: "단가산출", 키: "", 입력: {} }));
it.줄.push({ 꼴: "공식", 기호: [id] });
} else if (kind === "글") it.줄.push({ 꼴: "글", 글: "" });
});
}
/** 줄 옮김 — 항목 끝을 넘으면 옆 항목으로 */
function move(items: StackItem[], i: number, j: number, by: -1 | 1): void {
const [line] = items[i].줄.splice(j, 1);
const to = j + by;
if (to < 0 && i > 0) items[i - 1].줄.push(line);
else if (to > items[i].줄.length && i < items.length - 1) items[i + 1].줄.unshift(line);
else items[i].줄.splice(Math.max(0, Math.min(to, items[i].줄.length)), 0, line);
}
/** 줄 지움 — 금액 줄은 그 줄이 가진 연결 기호까지 · 공식 묶음은 기호까지 */
function dropLine(d: StackDoc, items: StackItem[], i: number, j: number): void {
const line = items[i].줄[j];
if (line.꼴 === "금액") {
const own = ownedOf(d).get(line.i);
if (own) dropSyms(d, [own]);
} else if (line.꼴 === "공식") dropSyms(d, line.기호);
items[i].줄.splice(j, 1);
}
export function buildEdit(h: EditHooks): HTMLElement {
const add = (at: number): HTMLElement =>
createSelectField({
options: [
{ value: "", text: `${lt("Add_Line")} ▾` },
...(["인력", "재료", "기계", "단가산출", "율"] as MoneyKind[]).map((k) => ({
value: k,
text: lt(KIND_KEY[k]),
})),
{ value: "공식", text: lt("K_새공식") },
{ value: "틀", text: lt("K_틀") },
...h
.templates()
.map((t) => ({ value: `tpl:${t.id}`, text: `${lt("K_공식")} — ${t.name}` })),
{ value: "글", text: lt("K_글") },
],
value: "",
compact: true,
size: 24,
ariaLabel: lt("Add_Line"),
onChange: (x) => x && addLine(h, at, x),
}).root;
const items = h.items.map((it, i) => {
const head = el("div", {
className: "lstk-ihead",
children: [
field(lt("Title"), it.제목, (t) => h.change((_, items) => (items[i].제목 = t.trim())), "l"),
createCheckbox(lt("Gana"), {
checked: !!it.가나다,
size: 24,
onChange: (c) =>
h.change((_, items) => (c ? (items[i].가나다 = true) : delete items[i].가나다)),
}).root,
add(i),
btn(
lt("Del_Item"),
() =>
h.change((d, items) => {
for (let j = items[i].줄.length - 1; j >= 0; j--) dropLine(d, items, i, j);
items.splice(i, 1);
}),
"danger-ghost",
),
],
});
const lines = it.줄.map((line, j) => {
const tools = el("div", {
className: "lstk-ltools",
children: [
btn("↑", () => h.change((_, items) => move(items, i, j, -1))),
btn("↓", () => h.change((_, items) => move(items, i, j, 1))),
btn(lt("Del"), () => h.change((d, items) => dropLine(d, items, i, j)), "danger-ghost"),
],
});
const body =
line.꼴 === "금액"
? moneyBody(h, line.i)
: line.꼴 === "글"
? [
field(
lt("K_글"),
line.글,
(t) => h.change((_, items) => ((items[i].줄[j] as { 글: string }).글 = t)),
"l",
),
]
: buildBlock(h, i, j, line);
const card = el("div", { className: `lstk-line k-${line.꼴}`, children: [tools, ...body] });
card.dataset.loc = `${i}.${j}`;
return card;
});
return el("div", {
className: "lstk-item",
children: [
head,
...(lines.length
? lines
: [el("div", { className: "lstk-empty", text: lt("Empty_Item") })]),
],
});
});
return el("div", {
className: "lstk-edit",
children: [
...items,
btn(lt("Add_Item"), () => h.change((_, items) => items.push({ 제목: "", 줄: [] }))),
],
});
}
+344
View File
@@ -0,0 +1,344 @@
/* =============================================================================
* 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 { 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;
}
export interface OutHooks {
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;
/** 식 글 → 조판(못 풀면 글) */
function exprNode(text: unknown, side: "sym" | "val", vals: Values, inline = false, cost?: string) {
const n = nodeOf(text);
return n
? katexNode(tex(n, { side, vals, inline, cost }))
: 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): Row[] {
const out: Row[] = [];
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">): void =>
void out.push({ ...r, depth, ...(depth ? { cols: undefined } : {}) });
const shown = new Set(reach(doc, vals)); // 지금 탄 갈래로 닿는 기호만
const steps = lineMap(doc);
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,
h: [`${sub.이름 ?? ""}${sub.결과단위 ? ` [${sub.결과단위}]` : ""}`],
cls: "subhead",
});
out.push(...rows(sub, got, h, depth + 1, path));
};
/** 공식 묶음 — 값만인 줄은 넷까지 한 줄 */
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) };
money.slice(0, i).forEach((m, k) => {
if (m.기호) scope[m.기호] = (a.줄?.[k] as { 금액?: unknown } | undefined)?.금액; // 앞 금액 줄 기호 = 그 줄 금액
});
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), " = ", 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[]> = { 노무비: [], 재료비: [], 경비: [] };
let n = 0;
let sub = 0;
for (const it of items) {
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;
for (const l of it.줄) {
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);
}
}
}
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 (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" });
for (const c of COSTS) {
const t = 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,
};
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.depth));
const tr = tableRow(columns, [first, cell("sum"), ...COSTS.map(cell)]);
tr.className = `lstk-orow ${r.cls ?? ""} ${r.depth ? "is-sub" : ""}`;
tbody.append(tr);
}
root.classList.add("lstk-out");
return root;
}
+253
View File
@@ -0,0 +1,253 @@
/* =============================================================================
* line_stack_rows.ts
* 줄 쌓기 공식 묶음(PLAN 804-2 · 요구 675) — 「이름 = 값」 · 「이름 = 식」 줄 묶음 · 틀 이름표 · [식으로 풀기] · [틀로 저장]
* 접힘 = 값 줄은 값 칸(연결 값을 고쳐 적으면 숫자 = 고정값 · 요구 664) · 식 줄은 「이름 = 식 = 값」 글 · 단가산출 결과 기호 = [식으로 풀기]
* 풀림 = 줄마다 이름 · 식 · 단위 칸 + 지움 · [+ 줄] — 이름 바꿈 = 문서 전부(`rename`) · 식 = 식 줄 · 수 = 숫자 연결
* 틀 = 단가산출 결과 문서(굴착기 Q 등)를 이 문서 식으로 옮김(`unfold`) · 내 틀 = 이 브라우저 저장(localStorage) — 꼴이 바뀌면
* 「내 틀 (틀 이름 에서 변경)」
* ========================================================================== */
import { createButton } from "@ui/ui_template_button";
import { eqText } from "@ui/ui_template_equation";
import { askText, el, showToast } from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
import {
DocError,
rename,
splitId,
varId,
type FormulaDoc,
} from "../common_util/common_util_formula_doc";
import { isNum } from "../common_util/common_util_formula_doc_calc";
import {
dropSyms,
freshId,
newVar,
setRow,
shapeOf,
unfold,
type StackDoc,
type StackItem,
type StackLine,
} from "../common_util/common_util_line_stack";
import type { EditHooks } from "./line_stack_edit";
import { lt } from "./line_stack_text";
/** 내 틀 — 기호 · 식 조각(이 브라우저) */
export interface Tpl {
id: string;
name: string;
문서: FormulaDoc;
}
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
type Block = Extract<StackLine, { 꼴: "공식" }>;
/** 틀 넣기 — 새 공식 묶음 줄 */
export function insertTpl(d: StackDoc, t: Tpl): Block {
const moved = unfold(d, t.문서);
return { 꼴: "공식", 기호: moved, 틀: `my:${t.id}`, 틀꼴: shapeOf(d, moved) };
}
const LINK_TAG: Record<string, string> = {
표: "표",
요소: "요소",
단가산출: "단가산출",
선택: "선택",
입력: "입력",
};
const input = (
value: string,
placeholder: string,
cls: string,
set: (t: string) => void,
): HTMLElement => {
const f = createInputField({ value, placeholder, compact: true, size: 24 });
f.root.classList.add(cls);
f.input.addEventListener("change", () => set(f.input.value));
return f.root;
};
export function buildBlock(h: EditHooks, i: number, j: number, line: Block): HTMLElement[] {
const d = h.doc;
const at = `${i}.${j}`;
const open = h.open.has(at);
const vals = (h.answer.값 ?? {}) as Record<string, unknown>;
const vars = new Map((d.변수 ?? []).map((v) => [varId(v), v]));
const lines = new Map((d.식 ?? []).map((l) => [String(l.기호 ?? ""), String(l.식 ?? "")]));
const blockOf = (items: StackItem[]): Block => items[i].줄[j] as Block;
const valText = (id: string): string => {
const v = vals[id];
if (isNum(v) || typeof v === "string") return `${eqText(v)} ${vars.get(id)?.단위 ?? ""}`.trim();
if (v && typeof v === "object")
return ["노무비", "재료비", "경비"]
.map((c) => `${c} ${eqText((v as Record<string, unknown>)[c])}`)
.join(" · ");
return `— ${h.answer.빈값?.[id] ?? ""}`.trim();
};
// ── 이름표 ──
const tplName = (k: string): string =>
k.startsWith("my:")
? (h.templates().find((t) => `my:${t.id}` === k)?.name ?? lt("My_Tpl"))
: (h.subDoc(k)?.이름 ?? (h.name(k) || k));
const changed = !!line.틀 && !!line.틀꼴 && shapeOf(d, line.기호) !== line.틀꼴;
const label = !line.틀
? lt("K_공식")
: changed
? `${lt("My_Tpl")} (${tplName(line.틀)} ${lt("Changed_From")})`
: tplName(line.틀);
const head = el("div", {
className: "lstk-fhead",
children: [
el("span", { className: "lstk-kind", text: label }),
createButton({
label: open ? lt("Fold") : lt("Unfold"),
variant: "ghost",
size: 24,
onClick: () => {
if (open) h.open.delete(at);
else h.open.add(at);
h.change(() => undefined);
},
}),
createButton({
label: lt("Save_Tpl"),
variant: "ghost",
size: 24,
onClick: () =>
void askText({ message: lt("Tpl_Name"), value: label }).then((name) => {
if (!name?.trim()) return;
const ids = new Set(line.기호);
const id = `t${Date.now()}`;
h.saveTpl({
id,
name: name.trim(),
문서: {
변수: clone((d.변수 ?? []).filter((v) => ids.has(varId(v)))),
식: clone((d.식 ?? []).filter((l) => ids.has(String(l.기호 ?? "")))),
},
});
h.change((doc, items) =>
Object.assign(blockOf(items), { 틀: `my:${id}`, 틀꼴: shapeOf(doc, line.기호) }),
);
}),
}),
],
});
head.dataset.tpl = changed ? "changed" : line.틀 ? "tpl" : "";
// ── 줄 ──
const renameRow = (old: string, now: string): void => {
const t = now.trim();
if (!t || t === old) return;
try {
const copy = clone(d);
const [s, lo, up] = splitId(t);
rename(copy, old, s, lo, up);
} catch (e) {
if (e instanceof DocError) return void showToast(lt("Bad_Name"), "warning");
throw e;
}
h.change((doc, items) => {
const [s, lo, up] = splitId(t);
rename(doc, old, s, lo, up);
for (const it of items)
for (const l of it.줄) if (l.꼴 === "공식") l.기호 = l.기호.map((x) => (x === old ? t : x));
});
};
const unfoldRow = (id: string, sub: FormulaDoc, key: string): void =>
h.change((doc, items) => {
const moved = unfold(doc, sub, id);
const blk = blockOf(items);
blk.기호 = blk.기호.filter((x) => x !== id);
items[i].줄.splice(j + 1, 0, { 꼴: "공식", 기호: moved, 틀: key, 틀꼴: shapeOf(doc, moved) });
if (!blk.기호.length) items[i].줄.splice(j, 1);
});
const rows = line.기호.map((id) => {
const v = vars.get(id);
const link = v?.연결;
const expr = lines.get(id);
const sub = link?.갈래 === "단가산출" && link.키 ? h.subDoc(link.키) : null;
const tag =
link && LINK_TAG[link.갈래 ?? ""] && !expr
? el("span", {
className: "lstk-tag",
text: `${LINK_TAG[link.갈래!]}${link.키 && !link.키.includes("{") ? ` · ${sub?.이름 ?? h.name(link.키) ?? ""}` : ""}`,
})
: null;
const tools: HTMLElement[] = [];
if (sub?.결과 && link?.키)
tools.push(
createButton({
label: lt("Unfold"),
variant: "ghost",
size: 24,
onClick: () => unfoldRow(id, sub, link.키!),
}),
);
if (link?.갈래 === "단가산출" && !link.키)
tools.push(el("span", { className: "lstk-hint", text: lt("K_틀") }));
const name = el("b", { className: "lstk-sym", text: id });
const valueOf =
expr ??
(link?.갈래 === "숫자" ? String(link.값 ?? "") : isNum(vals[id]) ? String(vals[id]) : "");
let body: (HTMLElement | string)[];
if (open)
body = [
input(id, lt("Row_Name"), "lstk-f-s", (t) => renameRow(id, t)),
"=",
input(valueOf, lt("Row_Expr"), "lstk-f-l", (t) => h.change((doc) => setRow(doc, id, t))),
input(v?.단위 ?? "", lt("Unit"), "lstk-f-s", (t) =>
h.change((doc) => {
let x = doc.변수?.find((y) => varId(y) === id);
if (!x) (doc.변수 ??= []).push((x = newVar(id, { 갈래: "식" })));
x.단위 = t.trim();
}),
),
el("span", { className: "lstk-val", text: `= ${valText(id)}` }),
createButton({
label: "×",
variant: "danger-ghost",
size: 24,
onClick: () => h.change((doc) => dropSyms(doc, [id])),
}),
];
else if (expr !== undefined || sub || link?.갈래 === "단가산출")
body = [
name,
`= ${expr ?? ""}${expr ? " =" : ""}`,
el("span", { className: "lstk-val", text: valText(id) }),
];
else
body = [
name,
"=",
input(valueOf, valText(id), "lstk-f-m", (t) => h.change((doc) => setRow(doc, id, t))),
el("span", { className: "lstk-unit", text: v?.단위 ?? "" }),
];
const row = el("div", {
className: `lstk-frow ${open ? "is-open" : ""}`,
children: [...body, ...(tag ? [tag] : []), ...tools],
});
row.dataset.sym = id;
return row;
});
const more = open
? [
createButton({
label: lt("Add_Row"),
variant: "ghost",
size: 24,
onClick: () =>
h.change((doc, items) => {
const id = freshId(doc, "x");
(doc.변수 ??= []).push(newVar(id, { 갈래: "숫자", 값: 0 }));
blockOf(items).기호.push(id);
}),
}),
]
: [];
return [head, ...rows, ...more];
}
+411
View File
@@ -0,0 +1,411 @@
/* =============================================================================
* line_stack_screen.ts
* 단가산출 「줄 쌓기」 탭(PLAN 804-2 · 요구 670~677) — 공용(M01 단가산출 기초 · M02 단가산출 = 길 앞머리만 바꿈)
* 위 = 옵션 가로(수식 탭과 같은 부품 `formula_doc_options.ts`) · [고침 버림] · [수식 저장](관리자 · 층 쓰기)
* 가운데 = 왼쪽 줄 쌓기 편집(`line_stack_edit.ts`) | 오른쪽 산출근거 출력(`line_stack_out.ts` · 기계 시간당 경비 펼침)
* 아래 = 합계 상자(노무비 · 재료비 · 경비 · 계 — 수식 탭과 같은 부품)
* 정본 = 수식 문서(수식 탭과 같은 문서 · 같은 길 · 같은 캐시 열쇠 — 탭을 바꾸면 다시 읽음) · 줄 쌓기 차림 = 문서 칸 `쌓기`
* 셈 = 브라우저 계산기(`calcDoc`) 즉시 · 캐시에 없는 연결만 뒤에서 `POST {길}/preview`(옛 요청 끊음)
* 고침 = 캐시(sessionStorage)에 쌓고 [수식 저장] 한 번에 `POST {길}/save`(서버가 정본으로 다시 셈) · 자동저장 없음
* M02 사본(`copy`) — 저장 몸 {문서, 끊음(수식 탭이 끊은 문서 그대로), 판} · [단가 갱신] · [재생성]은 수식 탭
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { createBadge } from "@ui/ui_template_badge";
import { createButton } from "@ui/ui_template_button";
import {
el,
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { blankDoc, type FormulaDoc } from "../common_util/common_util_formula_doc";
import { calcDoc, type DocAnswer } from "../common_util/common_util_formula_doc_calc";
import { createDocCache, type DocRecord } from "../common_util/common_util_formula_doc_cache";
import {
docOf,
notesOf,
stackOf,
type StackDoc,
type StackItem,
} from "../common_util/common_util_line_stack";
import { copyBadges, type CopyMark, type CopyOptions } from "./formula_doc_copy";
import type { Pick } from "./formula_doc_defs";
import { docFoot } from "./formula_doc_foot";
import { buildOptions } from "./formula_doc_options";
import { ft } from "./formula_doc_text";
import { buildEdit } from "./line_stack_edit";
import { buildOut } from "./line_stack_out";
import type { Tpl } from "./line_stack_rows";
import { lt } from "./line_stack_text";
import "./formula_doc.css";
import "./line_stack.css";
export interface StackScreenOptions {
/** 길 앞머리(`/m01/logic/doc`) — GET ?key · POST /preview · /save · /links */
base: string;
admin: boolean;
/** sessionStorage 열쇠 — 수식 탭과 같게(같은 문서 고침을 이어 받음) */
cache: string;
copy?: CopyOptions;
}
type Docs = Record<string, FormulaDoc>;
interface Loaded {
문서: StackDoc;
셈: DocAnswer;
연결: DocRecord;
새?: boolean;
끊음?: Docs;
사본?: CopyMark;
판?: string;
}
const TPL_KEY = "lineStack.myTemplates";
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
export function createStackScreen(opt: StackScreenOptions) {
const head = el("div", { className: "lstk__head" });
const notes = el("div", { className: "lstk__notes" });
const left = el("div", { className: "lstk__left" });
const right = el("div", { className: "lstk__right" });
const foot = el("div", { className: "lstk__foot" });
const root = el("div", {
className: "lstk",
attrs: { "data-line-stack": "" },
children: [head, notes, el("div", { className: "lstk__areas", children: [left, right] }), foot],
});
const cp = opt.copy;
let key = "";
let shown: string | null = null; // 연 키(같은 키 다시 그림 = 안 읽음 · 탭 바꿈 = force)
let saved = "";
let doc: StackDoc | null = null;
let answer: DocAnswer | null = null;
let cache = createDocCache();
let given: Record<string, unknown> = {};
let cut: Docs = {};
let mark: CopyMark | undefined;
let version = "";
let seq = 0;
let asked = "";
let ctl: AbortController | null = null;
const opened = new Set<string>(); // 산출근거 펼친 자리
const blocks = new Set<string>(); // 풀린 공식 묶음
const call = async <T>(path: string, body?: unknown, signal?: AbortSignal): Promise<T> => {
const r = await fetch(`${API_BASE_URL}${cp ? cp.route(key, path) : opt.base + path}`, {
method: body === undefined ? "GET" : "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
signal,
});
const data = (await r.json().catch(() => ({}))) as T & { detail?: unknown };
if (!r.ok) {
const why = data.detail;
throw Object.assign(new Error(typeof why === "string" ? why : r.statusText), {
status: r.status,
});
}
return data;
};
// ── 캐시(수식 탭과 같은 열쇠) ──
const drafts = (): Record<string, FormulaDoc> => {
try {
return JSON.parse(sessionStorage.getItem(opt.cache) ?? "{}") as Record<string, FormulaDoc>;
} catch {
return {};
}
};
const dirty = (): boolean => !!doc && JSON.stringify(doc) !== saved;
const keep = (): void => {
const all = drafts();
if (dirty()) all[key] = doc!;
else delete all[key];
try {
sessionStorage.setItem(opt.cache, JSON.stringify(all));
} catch {
/* 캐시가 막혀도 화면 안 고침은 남음 */
}
};
const templates = (): Tpl[] => {
try {
return JSON.parse(localStorage.getItem(TPL_KEY) ?? "[]") as Tpl[];
} catch {
return [];
}
};
// ── 셈 ──
const usable = (): Record<string, unknown> => {
const out: Record<string, unknown> = {};
for (const v of doc?.변수 ?? []) {
const k = v.열쇠;
if (!k || !(k in given)) continue;
const kind = v.연결?.갈래;
if (kind === "입력") out[k] = given[k];
if (kind === "선택" && (v.연결?.목록 ?? []).some((x) => String(x) === String(given[k])))
out[k] = (v.연결!.목록 ?? []).find((x) => String(x) === String(given[k]));
}
return out;
};
const recalc = (): void => {
if (!doc) return;
answer = calcDoc(doc, cache.links, usable());
if (cache.takeMissing()) ask();
};
/** 캐시에 없는 연결 — 뒤에서 서버 기록을 받아 더함 */
const ask = (): void => {
const snap = JSON.stringify([doc, usable()]);
if (snap === asked) return;
asked = snap;
ctl?.abort();
ctl = new AbortController();
const mine = ++seq;
void call<{ 연결: DocRecord }>(
"/preview",
cp ? { 문서: doc, 끊음: cut } : { 문서: key ? doc : { ...doc, 키: "_새" }, 입력: usable() },
ctl.signal,
)
.then((got) => {
if (mine !== seq) return;
cache.merge(got.연결);
cache.merge({ 문서: cut });
recalc();
draw();
})
.catch(() => undefined);
};
const subDoc = (k: string): FormulaDoc | null => {
try {
return cache.links.doc(k) ?? null;
} catch {
if (cache.takeMissing()) ask();
return null;
}
};
/** 고침 — 문서 · 차림을 고친 뒤 문서로 굳힘 */
const change = (fn: (d: StackDoc, items: StackItem[]) => void): void => {
if (!doc) return;
const items = stackOf(doc);
fn(doc, items);
doc = docOf(doc, items);
keep();
recalc();
draw();
};
// ── 그림 ──
const edit = (): boolean => opt.admin && (!!key || !cp);
const draw = (): void => {
if (!doc || !answer) {
[head, notes, left, right, foot].forEach((x) => x.replaceChildren());
return;
}
const options = buildOptions(doc, given, !!cp && !opt.admin, (k, v, x) => {
const at = doc?.변수?.indexOf(v) ?? -1;
if (cp) return change((d) => at >= 0 && (d.변수![at].연결 = { ...v.연결!, 값: x }));
given[k] = x;
recalc();
draw();
});
const busy = dirty();
const tools =
edit() && key
? [
createButton({
label: ft("Discard"),
variant: "ghost",
size: 28,
disabled: !busy,
onClick: () => void discard(),
}),
createButton({
label: ft("Save"),
variant: "filled",
size: 28,
disabled: !busy,
onClick: () => void save(),
}),
]
: [];
head.replaceChildren(
...(options.length ? [el("div", { className: "fdoc__options", children: options })] : []),
el("div", {
className: "lstk__bar",
children: [
el("div", {
className: "lstk__badges",
children: [
...(busy ? [createBadge(ft("Dirty"), "warning")] : []),
...(cp ? copyBadges(mark, opt.admin) : []),
],
}),
el("div", { className: "lstk__tools", children: tools }),
],
}),
);
const kept = notesOf(doc);
notes.replaceChildren(
...(kept.length
? [
el("p", {
className: "ui-text ui-text--muted",
text: `${lt("Notes")} — ${kept.join(" · ")}`,
}),
]
: []),
);
const items = stackOf(doc);
root.dataset.edit = edit() ? "1" : "";
left.replaceChildren(
...(edit()
? [
buildEdit({
doc,
answer,
items,
change,
ask: (kind, words, group) =>
call<{ 후보: Pick[] }>("/links", { 갈래: kind, 검색: words, 분류: group }),
subDoc,
name: (k) => cache.names.get(k) ?? "",
open: blocks,
templates,
saveTpl: (t) => localStorage.setItem(TPL_KEY, JSON.stringify([...templates(), t])),
}),
]
: []),
);
right.replaceChildren(
items.length || doc.결과
? buildOut(doc, answer, {
cache,
open: opened,
toggle: (at) => {
if (opened.has(at)) opened.delete(at);
else opened.add(at);
draw();
},
miss: ask,
})
: el("p", { className: "ui-text ui-text--muted", text: lt("Empty") }),
);
foot.replaceChildren(...(doc.결과 ? [] : [docFoot(answer)]));
};
const take = (got: Loaded): void => {
cache.merge(got.연결);
saved = JSON.stringify(got.문서);
doc = clone(got.문서);
cut = clone(got.끊음 ?? {});
mark = got.사본;
version = got.판 ?? "";
};
const save = async (): Promise<void> => {
if (!doc || !dirty()) return;
const kept = drafts()[`${key}#끊음`] as unknown as Docs | undefined; // 수식 탭이 끊은 문서 고침
const send = cp ? { 문서: doc, 끊음: kept ?? cut, 판: version } : { 문서: doc };
showLoadingOverlay();
try {
const got = await call<Loaded>("/save", send);
take(got);
const all = drafts();
delete all[`${key}#끊음`];
sessionStorage.setItem(opt.cache, JSON.stringify(all));
keep();
recalc();
draw();
if (cp && got.판) cp.onSaved?.(got.판);
showToast(ft("Saved"), "success");
} catch (e) {
showToast(`${ft("Save_Failed")} — ${e instanceof Error ? e.message : ""}`, "error");
} finally {
hideLoadingOverlay();
}
};
const discard = async (): Promise<void> => {
if (!doc || !dirty()) return void draw();
doc = JSON.parse(saved) as StackDoc;
keep();
recalc();
draw();
};
/** 문서 열기 — 서버 · 캐시에서 다시 읽음(수식 탭 고침 · 저장을 이어 받음) */
const open = async (next: string): Promise<void> => {
const was = key;
shown = next;
if (next !== key) {
opened.clear();
blocks.clear();
given = {};
}
key = next;
doc = answer = null;
asked = "";
cache = createDocCache();
[cut, mark, version] = [{}, undefined, ""];
draw();
if (!next) {
if (cp)
return void right.replaceChildren(
el("p", { className: "ui-text ui-text--muted", text: lt("Unsaved_New") }),
);
doc = (drafts()[""] as StackDoc | undefined) ?? blankDoc();
saved = JSON.stringify(blankDoc());
recalc();
return draw();
}
const mine = ++seq;
try {
const got = await call<Loaded>(cp ? "" : `?key=${encodeURIComponent(next)}`);
if (mine !== seq || key !== next) return;
take(got);
const all = drafts();
if (!was && got.새 && all[""] && !all[next]) {
all[next] = { ...all[""], 키: next, 이름: got.문서.이름, 결과단위: got.문서.결과단위 };
delete all[""];
sessionStorage.setItem(opt.cache, JSON.stringify(all));
}
if (all[next]) doc = clone(all[next] as StackDoc);
if (cp && all[`${next}#끊음`])
cache.merge({ 문서: (cut = clone(all[`${next}#끊음`] as unknown as Docs)) });
recalc();
draw();
} catch (e) {
if (mine === seq)
head.replaceChildren(
el("p", {
className: "ui-text ui-text--muted",
text: `${ft("Load_Failed")} — ${e instanceof Error ? e.message : ""}`,
}),
);
}
};
const canLeave = async (): Promise<boolean> => {
if (!dirty()) return true;
if (!(key in drafts())) return true; // 수식 탭에서 이미 버림
if (!(await showConfirmDialog(ft("Leave"), ft("Leave_Ok")))) return false;
doc = JSON.parse(saved) as StackDoc;
keep();
return true;
};
return {
root,
/** 로직 · SK 키로 그림(빈 키 = 저장 전 새 것) — 같은 키는 그대로 · `force` = 다시 읽음(탭 바꿈) */
draw: (next: string, force = false): void => {
if (force || next !== shown) void open(next);
},
canLeave,
/** 시험 · 확인용 */
state: () => ({ doc, answer, dirty: dirty(), items: doc ? stackOf(doc) : [] }),
};
}
+139
View File
@@ -0,0 +1,139 @@
/* =============================================================================
* line_stack_tex.ts
* 줄 쌓기 산출근거 수식 조판(PLAN 804-2 · 요구 677) — 식 나무 → TeX → KaTeX(MIT) · 분수 · 첨자 · 괄호
* 기호 식(side "sym") = 기호(첨자) · 값 식(side "val") = 값(모르면 기호 빨강) · 만약 = 탄 갈래만 · 반올림 · 올림 · 버림 감싸개 숨김
* 금액 줄 꼴(`inline`) = ÷ 한 줄(경쟁사 「노무비 : 59,020 ÷ Q」) · 공식 줄 = 분수
* 규칙은 수식 탭 그림(`formula_doc_render.ts` renderNode)과 같음 — 셈 정본은 계산기
* ========================================================================== */
import katex from "katex";
import "../config/node_modules/katex/dist/katex.min.css";
import { eqText } from "@ui/ui_template_equation";
import { splitId, type Node } from "../common_util/common_util_formula_doc";
import { isNum } from "../common_util/common_util_formula_doc_calc";
import { leafNum, takenOf, type Values } from "../common_util/common_util_formula_doc_view";
export interface TexCtx {
side: "sym" | "val";
vals: Values;
inline?: boolean;
/** 묶음 값의 비목(기계 · 단가산출 줄 노 · 재 · 경) */
cost?: string;
}
const HIDE = new Set(["반올림", "올림", "버림"]);
const OPS: Record<string, string> = {
"+": "+",
"-": "-",
"*": "\\times",
"/": "\\div",
"<": "<",
"<=": "\\le",
">": ">",
">=": "\\ge",
"==": "=",
"!=": "\\ne",
그리고: "\\;\\text{그리고}\\;",
또는: "\\;\\text{또는}\\;",
};
const PREC: Record<string, number> = {
또는: 1,
그리고: 2,
"<": 3,
"<=": 3,
">": 3,
">=": 3,
"==": 3,
"!=": 3,
"+": 4,
"-": 4,
"*": 5,
"/": 5,
};
const prec = (n: Node): number => (n[0] === "bin" ? PREC[n[1]] : n[0] === "neg" ? 6 : 9);
/** TeX 글 안전 — \text{} 안 */
const txt = (s: string): string => `\\text{${s.replace(/[\\{}$&#^_%~]/g, (c) => `\\${c}`)}}`;
const word = (s: string): string =>
/^[A-Za-z]$/.test(s) ? s : /^[A-Za-z]+$/.test(s) ? `\\mathit{${s}}` : txt(s);
/** 기호 글(`Q_작업^표`) → TeX(첨자) */
export function symTex(id: string): string {
const [s, lo, up] = splitId(id);
return `${word(s)}${lo ? `_{${word(lo)}}` : ""}${up ? `^{${word(up)}}` : ""}`;
}
/** 값 → TeX(천 자리 쉼표) */
export const valTex = (v: unknown): string => {
const t = eqText(v);
return isNum(v) ? t.replace(/,/g, "{,}") : txt(t);
};
const paren = (t: string): string => `\\left(${t}\\right)`;
const miss = (t: string): string => `\\htmlClass{lstk-miss}{${t}}`;
export function tex(n: Node, c: TexCtx): string {
const go = (x: Node): string => tex(x, c);
switch (n[0]) {
case "num":
return valTex(n[1]);
case "str":
return txt(`'${n[1]}'`);
case "var": {
if (c.side === "sym") return symTex(n[1]);
let v = c.vals[n[1]];
if (v && typeof v === "object" && c.cost) v = (v as Values)[c.cost];
return v === null || v === undefined || typeof v === "object"
? miss(symTex(n[1]))
: valTex(v);
}
case "field": {
if (c.side === "val" && n[1][0] === "var") {
const v = c.vals[n[1][1]];
const got = v && typeof v === "object" ? (v as Values)[n[2]] : undefined;
if (isNum(got)) return valTex(got);
}
return `${n[1][0] === "var" ? symTex(n[1][1]) : go(n[1])}.${txt(n[2])}`;
}
case "neg":
return `-${n[1][0] === "bin" ? paren(go(n[1])) : go(n[1])}`;
case "pct":
return `${prec(n[1]) < 9 ? paren(go(n[1])) : go(n[1])}\\,\\%`;
case "call": {
const [, name, items] = n;
if (name === "만약") {
const pick = takenOf(n, c.vals);
if (pick) return go(items[pick]);
}
if (HIDE.has(name) && items.length >= 1 && items.length <= 2) return go(items[0]);
const fn = name === "최소" ? "\\min" : name === "최대" ? "\\max" : txt(name);
return `${fn}${paren(items.map(go).join(",\\,"))}`;
}
case "bin": {
const [, o, a, b] = n;
const na = leafNum(a, c.vals);
const nb = leafNum(b, c.vals);
if ((o === "+" || o === "-") && nb === 0) return go(a);
if ((o === "*" || o === "/") && nb === 1) return go(a);
if ((o === "+" && na === 0) || (o === "*" && na === 1)) return go(b);
if (o === "/" && !c.inline) return `\\dfrac{${go(a)}}{${go(b)}}`;
const mine = PREC[o];
const left = prec(a) < mine || (mine === 3 && prec(a) === 3) ? paren(go(a)) : go(a);
const right = prec(b) <= mine ? paren(go(b)) : go(b);
return `${left} ${OPS[o] ?? txt(o)} ${right}`;
}
}
}
/** TeX → 조판 요소(못 그리면 글 그대로) */
export function katexNode(t: string): HTMLElement {
const box = document.createElement("span");
box.className = "lstk-tex";
katex.render(t, box, {
throwOnError: false,
strict: "ignore",
trust: (x) => x.command === "\\htmlClass",
output: "html",
});
return box;
}
+64
View File
@@ -0,0 +1,64 @@
/* =============================================================================
* line_stack_text.ts
* 줄 쌓기 화면 글(PLAN 804-2) — [국문, 영문] · 수식 탭과 같은 글은 `formula_doc_text.ts`
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
const TEXT = {
Tab: ["줄 쌓기", "Line stack"],
Basis: ["산 출 근 거", "Basis"],
Total: ["합 계", "Total"],
Sum: ["계", "Sum"],
Sub_Sum: ["소 계", "Subtotal"],
Add_Item: ["+ 항목", "+ Item"],
Add_Line: ["+ 줄", "+ Line"],
Del_Item: ["항목 지움", "Delete item"],
Del: ["지움", "Delete"],
Up: ["위로", "Up"],
Down: ["아래로", "Down"],
Title: ["항목 이름(비우면 번호 없음)", "Item title"],
Gana: ["가나다", "Sub"],
K_인력: ["인력", "Labor"],
K_재료: ["재료", "Material"],
K_기계: ["기계", "Machine"],
K_단가산출: ["다른 단가산출", "Unit price"],
K_율: ["율 · 식", "Rate"],
K_공식: ["공식", "Formula"],
K_새공식: ["공식 — 새 공식(빈 묶음)", "Formula — new"],
K_틀: ["공식 — 틀 찾기(단가산출 결과)", "Formula — template"],
K_글: ["글 · 근거", "Text"],
Name: ["이름", "Name"],
Unit: ["단위", "Unit"],
Cost: ["비목", "Cost"],
Qty: ["수량", "Qty"],
Price: ["단가", "Price"],
Machine: ["기계", "Machine"],
Find: ["찾기", "Find"],
Pick: ["고름 ▾", "Pick ▾"],
None_Found: ["찾은 것 없음", "Nothing found"],
Unfold: ["식으로 풀기", "Unfold"],
Fold: ["접기", "Fold"],
Save_Tpl: ["틀로 저장", "Save as template"],
Tpl_Name: ["틀 이름", "Template name"],
New_Formula: ["새 공식", "New formula"],
My_Formula: ["내 공식", "My formula"],
My_Tpl: ["내 틀", "My template"],
Changed_From: ["에서 변경", "changed"],
Add_Row: ["+ 「이름 = 식」 줄", "+ Row"],
Row_Name: ["이름", "Name"],
Row_Expr: ["값 또는 식", "Value or expr"],
Bad_Name: ["못 쓰는 이름 · 같은 이름 있음", "Bad or duplicate name"],
Empty_Item: ["줄 없음 — [+ 줄]", "No lines — [+ Line]"],
Empty: ["빈 단가산출 — [+ 항목] 으로 시작", "Empty — start with [+ Item]"],
Text_Ph: ["< 품셈 근거 > · ※ 메모 · 별산", "Note"],
Notes: [
"줄 쌓기에서 못 고치는 칸(보존됨 · 수식 탭에서 고침)",
"Kept fields (edit in Equation tab)",
],
Unsaved_New: ["저장한 단가산출만 — 페이지 [저장] 뒤", "Saved unit prices only"],
} as const;
export type LsKey = keyof typeof TEXT;
export const lt = (key: LsKey): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
+18 -4
View File
@@ -44,6 +44,7 @@ import { saveRevised } from "./M01_MasterData_UI_LogicLab_Revise";
import { buildLogicSide } from "./M01_MasterData_UI_LogicLab_Side";
import { createViewPicker } from "./M01_MasterData_UI_LogicLab_View";
import { createDocScreen } from "../A00_Common/formula_doc_screen";
import { createStackScreen } from "../A00_Common/line_stack_screen";
import { buildHeadBar } from "./M01_MasterData_UI_Head";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import "./M01_MasterData_UI_Logic_Style.css";
@@ -109,10 +110,21 @@ export async function mountM01LogicLab(
const drive = createDrive();
// 802-3 수식 = 수식 문서 화면(공용 · 옛 수식 나무 화면은 M01 에서 걷음)
const equation = createDocScreen({ base: "/m01/logic/doc", admin, cache: "m01_doc_drafts" });
// 804-2 줄 쌓기 = 같은 수식 문서 · 같은 캐시 — 탭을 바꾸면 고른 쪽이 다시 읽음
const stack = createStackScreen({ base: "/m01/logic/doc", admin, cache: "m01_doc_drafts" });
const leave = (): Promise<boolean> => equation.canLeave().then((ok) => ok && stack.canLeave());
const editor = el("div", { className: "m01-logic__editor" });
// 로직 상세 — 왼쪽 지금 내용 · 오른쪽 접는 패널 「원문」
const panes = buildLogicSide(editor);
const views = createViewPicker(editor); // 보기 고르기 — 머리줄 [신규 로직] 왼쪽
// 보기 고르기 — 머리줄 [신규 로직] 왼쪽
const views = createViewPicker(editor, (v) => {
const k = opened?.origKey;
if (v === "stack") stack.draw(k ?? "", true);
else if (v === "equation" && k) {
equation.draw("");
equation.draw(k);
}
});
const errors = el("div", {
className: "ui-banner ui-banner--danger m01-logic__reasons",
attrs: { hidden: "" },
@@ -128,7 +140,7 @@ export async function mountM01LogicLab(
const back = createButton({
label: tx("Bar_Back"),
variant: "ghost",
onClick: () => void equation.canLeave().then((ok) => ok && show(null)),
onClick: () => void leave().then((ok) => ok && show(null)),
});
const persist = (): void => {
@@ -243,8 +255,10 @@ export async function mountM01LogicLab(
rerun: () => void drive.run(),
admin,
equation: equation.root,
stack: stack.root,
});
equation.draw(current.origKey ?? "");
stack.draw(current.origKey ?? "");
};
/** 단가산출 상세 옵션 입력 → 시험 입력(이름이 있는 칸만) · 바뀌면 다시 셈 */
@@ -320,7 +334,7 @@ export async function mountM01LogicLab(
restore: true,
onPick: (sub, detail) => {
list.setPick(sub, detail);
if (opened) void equation.canLeave().then((ok) => ok && show(null));
if (opened) void leave().then((ok) => ok && show(null));
},
}),
);
@@ -501,7 +515,7 @@ export async function mountM01LogicLab(
show(null);
showLoadingOverlay();
const openKeyOn = async (key: string): Promise<void> => {
if (!(await equation.canLeave())) return;
if (!(await leave())) return;
const found = allLogics.find((x) => x.키 === key);
if (found) await open(logicId(found.구분, found.키), found.구분, found.키);
else showToast(tx("Load_Failed"), "error");
@@ -38,6 +38,8 @@ export interface DetailContext extends HoContext {
cover?: { ho: HTMLElement; open?: ReturnType<typeof lineOpener> };
/** 796-2 수식 탭(`_LogicLab_Equation`) 그릇 — 있으면 「수식」 덩이(M02 는 795-1 사본 꼴 뒤에 이음) */
equation?: HTMLElement;
/** 804-2 줄 쌓기 탭 그릇(`A00_Common/line_stack_screen.ts`) */
stack?: HTMLElement;
}
const section = (title: string, body: HTMLElement[], hint = ""): HTMLElement =>
@@ -229,6 +231,7 @@ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void {
host.replaceChildren(
...infoBox(ctx),
...(ctx.equation ? [block(ctx.equation, "equation")] : []),
...(ctx.stack ? [block(ctx.stack, "stack")] : []),
ctx.cover ? block(section(tl("Ho_Title"), [ctx.cover.ho]), "ho") : hoBox(ctx),
);
const own = kept?.node.dataset.own; // 기본정보 칸은 새로 만들어지므로 이름표로 다시 찾음
@@ -179,6 +179,7 @@
/* 보기 고르기(710-2 · `_View.ts`) — 그릇 data-view 와 다른 이름표 덩이는 숨김 */
.m01-logic__editor[data-view="equation"] [data-block]:not([data-block="equation"]),
.m01-logic__editor[data-view="stack"] [data-block]:not([data-block="stack"]),
.m01-logic__editor[data-view="ho"] [data-block]:not([data-block="ho"]) {
display: none;
}
@@ -1,7 +1,7 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_View.ts
* 「단가산출 로직」 상세 보기 고르기 — 머리줄 [신규 로직] 왼쪽 「분할 고르기 | 세로 나눔선」
* 보기 둘 = 수식(기본 · 796-2) · 단가산출 상세 — 고른 덩이만 보임(시트 · 인력·재료·경비 · 시험 계산 · 텍스트 수식은 799-2 에 걷음)
* 보기 셋 = 수식(기본 · 796-2) · 줄 쌓기(804-2 · `A00_Common/line_stack_screen.ts`) · 단가산출 상세 — 고른 덩이만 보임(시트 · 인력·재료·경비 · 시험 계산 · 텍스트 수식은 799-2 에 걷음)
* 덩이 = `data-block` 이름표 · 숨김 = 그릇 `data-view` + CSS(`LogicLab_Style.css`) — 다시 그려도 고름 유지
* 셈 구동은 화면 칸 없이 `_Logic_Drive.ts` · 기본정보는 이름표 없음(늘 맨 위)
* 기억 = 이 화면 안에서만(로직을 바꿔도 유지 · 새로 들어오면 수식)
@@ -9,9 +9,10 @@
import { createSegment } from "@ui/ui_template_segment";
import { createToolbar } from "@ui/ui_template_toolbar";
import { lt } from "../A00_Common/line_stack_text";
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
export type LabView = "equation" | "ho";
export type LabView = "equation" | "stack" | "ho";
/** 덩이에 보기 이름표를 붙여 돌려줌 */
export const block = <T extends HTMLElement>(node: T, view: LabView): T => {
@@ -20,17 +21,24 @@ export const block = <T extends HTMLElement>(node: T, view: LabView): T => {
};
/** 보기 고르기 — `host`(상세 그릇)에 `data-view` 를 둠 · `root` 는 머리줄에 놓음 */
export function createViewPicker(host: HTMLElement): { root: HTMLElement } {
export function createViewPicker(
host: HTMLElement,
onChange?: (v: LabView) => void,
): { root: HTMLElement } {
host.dataset.view = "equation";
const segment = createSegment({
options: [
{ value: "equation", label: tl("View_Equation") },
{ value: "stack", label: lt("Tab") },
{ value: "ho", label: tl("Ho_Title") },
],
value: "equation",
size: 32,
ariaLabel: tl("View_Label"),
onChange: (v) => (host.dataset.view = v),
onChange: (v) => {
host.dataset.view = v;
onChange?.(v as LabView);
},
});
return { root: createToolbar({ items: [segment.root, "divider"], label: tl("View_Label") }) };
}
@@ -1,6 +1,7 @@
/* =============================================================================
* M02_MasterTemplete_Sangeun_Tabs.ts
* M02 단가산출(SK) 상세 보기 고르기(PLAN 799-2 · 802-4b-2 · 요구 655-8 · 660~665) — 수식(첫 탭) · 단가산출 상세 둘.
* M02 단가산출(SK) 상세 보기 고르기(PLAN 799-2 · 802-4b-2 · 804-2 · 요구 655-8 · 660~665 · 677) — 수식(첫 탭) · 줄 쌓기 · 단가산출 상세.
* 줄 쌓기 = 공용 줄 쌓기 화면(`A00_Common/line_stack_screen.ts`) — 같은 SK 수식 길 · 같은 캐시 · 탭을 바꾸면 고른 쪽이 다시 읽음
* 수식 = 공용 수식 문서 화면(`A00_Common/formula_doc_screen.ts` · M01 과 같은 꼴) — 길 앞머리만 M02 사본 길
* (`/m02/layers/{층}/sangeun/{키}/doc` · 802-4a) · 층 쓰기 권한 없으면 읽기만 · 저장 = 그 탭 [수식 저장](페이지 [저장] 아님)
* 옛 M02 수식 나무 화면(796-2b · 2c · 2e · 800 · 801)은 걷음 · 단가산출 상세 = 상세가 쥔 `editor`(수식일 때 숨김)
@@ -9,10 +10,13 @@
import { createSegment } from "@ui/ui_template_segment";
import { createDocScreen } from "../A00_Common/formula_doc_screen";
import { createStackScreen } from "../A00_Common/line_stack_screen";
import { lt } from "../A00_Common/line_stack_text";
import { el } from "@ui/ui_template_elements";
import { tl } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Text";
import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
type View = "equation" | "ho";
type View = "equation" | "stack" | "ho";
export interface SkEquationOptions {
scope: { layer: Layer; owner: string | null };
@@ -48,22 +52,49 @@ export function createSangeunTabs(
cache: "m02_sk_doc_drafts",
copy: { route: (k, p) => skDocPath(opt.scope, k, p), onSaved: opt.onSaved },
});
const stack = createStackScreen({
base: "",
admin: opt.edit,
cache: "m02_sk_doc_drafts",
copy: { route: (k, p) => skDocPath(opt.scope, k, p), onSaved: opt.onSaved },
});
let key = "";
let view: View = "equation";
const show = (v: View): void => {
view = v;
screen.root.hidden = v !== "equation";
editor.hidden = v === "equation";
stack.root.hidden = v !== "stack";
editor.hidden = v !== "ho";
if (!editor.hidden) editor.dataset.view = v;
};
const picker = createSegment({
options: [
{ value: "equation", label: tl("View_Equation") },
{ value: "stack", label: lt("Tab") },
{ value: "ho", label: tl("Ho_Title") },
],
value: "equation",
size: 32,
ariaLabel: tl("View_Label"),
onChange: (v) => show(v as View),
onChange: (v) => {
show(v as View);
if (v === "stack") stack.draw(key, true);
else if (v === "equation" && key) {
screen.draw("");
screen.draw(key);
}
},
});
picker.root.dataset.skViews = "";
show("equation");
return { picker: picker.root, host: screen.root, draw: screen.draw, canLeave: screen.canLeave };
return {
picker: picker.root,
host: el("div", { className: "m02-sk__views", children: [screen.root, stack.root] }),
draw: (k: string) => {
key = k;
screen.draw(k);
if (view === "stack") stack.draw(k);
},
canLeave: () => screen.canLeave().then((ok) => ok && stack.canLeave()),
};
}
+429
View File
@@ -0,0 +1,429 @@
/* =============================================================================
* common_util_line_stack.ts
* 줄 쌓기 모델(PLAN 804-2 · 요구 670~677) — 수식 문서 ↔ 줄 쌓기 한 벌 왕복 · 화면 · DOM · fetch 없음(시험이 Node 로 돌림)
* 정본 = 수식 문서 그대로(변수 · 식 · 금액 · 끝수 …) · 줄 쌓기는 그 위의 차림 — 항목(제목 · 가나다) 안 줄:
* 금액 줄(문서 `금액` 차례) · 공식 묶음(기호 목록 · 틀) · 글 줄
* 차림 저장 = 문서 칸 `쌓기`(기본 차림과 다를 때만 · 계산기는 안 봄) — 문서 → 줄 → 문서 = 같은 문서(1,518 전수 시험)
* 기본 차림 = 금액 줄을 `묶음` 이 같은 이어진 줄끼리 항목으로 · 기호는 처음 쓰는 항목의 공식 묶음(쓰는 차례 = 입력 먼저)
* 옵션(선택 · 입력 열쇠) · 금액 줄 단가 하나만 쓰는 연결 기호(요소 · 단가산출)는 공식 묶음에 안 넣음(옵션 가로 · 그 줄이 보임)
* 금액 차례 = 줄 쌓기 차례(율 줄 「노무비」 = 앞 줄 누계라 차례가 셈을 바꿈)
* ========================================================================== */
import {
DocError,
names,
parse,
rename,
splitId,
symId,
textOf,
linkNames,
varId,
type DocVar,
type FormulaDoc,
type Link,
type MoneyLine,
type Node,
} from "./common_util_formula_doc";
import { evalNode } from "./common_util_formula_doc_view";
export type MoneyKind = "인력" | "재료" | "기계" | "단가산출" | "율";
export type StackLine =
| { 꼴: "금액"; i: number }
| { 꼴: "공식"; 기호: string[]; 틀?: string; 틀꼴?: string }
| { 꼴: "글"; 글: string };
export interface StackItem {
제목: string;
가나다?: boolean;
줄: StackLine[];
}
type SaveLine =
{ 금액: number; 이름: string } | { 공식: string[]; 틀?: string; 틀꼴?: string } | { 글: string };
export interface SaveItem {
제목?: string;
가나다?: boolean;
줄: SaveLine[];
}
export type StackDoc = FormulaDoc & { 쌓기?: SaveItem[] };
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
/** 식 글 → 나무(못 풀면 null) */
export function nodeOf(text: unknown): Node | null {
if (text === null || text === undefined || String(text).trim() === "") return null;
try {
return parse(text);
} catch (e) {
if (e instanceof DocError) return null;
throw e;
}
}
const namesIn = (text: unknown): string[] => {
const n = nodeOf(text);
return n ? names(n) : [];
};
/** 문서 기호 전부 — 변수 차례 · 변수 없는 식 줄 기호 */
export function symbols(doc: FormulaDoc): string[] {
const out = (doc.변수 ?? []).map(varId);
for (const l of doc.식 ?? []) {
const id = String(l.기호 ?? "");
if (id && !out.includes(id)) out.push(id);
}
return out;
}
export const isOption = (v: DocVar | undefined): boolean =>
!!v?.열쇠 && (v.연결?.갈래 === "선택" || v.연결?.갈래 === "입력");
/** 기호가 부르는 기호 — 식 줄 · 연결(조건 · 넘길 입력 · 구멍) */
export function usesOf(doc: FormulaDoc): Map<string, string[]> {
const out = new Map<string, string[]>();
const add = (id: string, more: string[]): void => {
const now = out.get(id) ?? [];
for (const n of more) if (!now.includes(n)) now.push(n);
out.set(id, now);
};
for (const v of doc.변수 ?? []) add(varId(v), v.연결 ? linkNames(v.연결) : []);
for (const l of doc.식 ?? []) add(String(l.기호 ?? ""), namesIn(l.식));
return out;
}
/** 단가 식이 기호 하나(또는 그 칸 꺼냄)면 그 기호 */
export function priceSym(line: MoneyLine): string | null {
const n = nodeOf(line.단가);
if (!n) return null;
if (n[0] === "var") return n[1];
if (n[0] === "field" && n[1][0] === "var") return n[1][1];
return null;
}
/** 금액 줄 하나만 단가로 쓰는 연결 기호(요소 · 단가산출) — {줄 차례: 기호} */
export function ownedOf(doc: FormulaDoc): Map<number, string> {
const count = new Map<string, number>();
const bump = (ns: string[]): void => ns.forEach((n) => count.set(n, (count.get(n) ?? 0) + 1));
for (const l of doc.금액 ?? []) bump([...namesIn(l.수량), ...namesIn(l.단가)]);
for (const ns of usesOf(doc).values()) bump(ns);
if (doc.결과) bump([doc.결과]);
const vars = new Map((doc.변수 ?? []).map((v) => [varId(v), v]));
const lines = new Set((doc.식 ?? []).map((l) => String(l.기호 ?? "")));
const out = new Map<number, string>();
(doc.금액 ?? []).forEach((l, i) => {
const s = priceSym(l);
const kind = s ? vars.get(s)?.연결?.갈래 : undefined;
if (s && (kind === "요소" || kind === "단가산출") && count.get(s) === 1 && !lines.has(s))
out.set(i, s);
});
return out;
}
/** 금액 줄 갈래 — 인력 · 재료(요소) · 기계(기계 시간당 경비 단가산출) · 단가산출 · 율(식) */
export function kindOf(
doc: FormulaDoc,
i: number,
look?: (key: string) => FormulaDoc | null,
): MoneyKind {
const line = (doc.금액 ?? [])[i] ?? {};
const s = priceSym(line);
const link = s ? (doc.변수 ?? []).find((v) => varId(v) === s)?.연결 : undefined;
if (link?.갈래 === "단가산출") {
const sub = link.키 && look ? look(link.키) : null;
const machine =
(link.입력 && "기계" in link.입력) ||
!!sub?.변수?.some((v) => v.열쇠 === "기계" && isOption(v));
return machine ? "기계" : "단가산출";
}
if (link?.갈래 === "요소") {
const lb = link.키 ? String(link.키).startsWith("LB") : line.비목 === "노무비"; // 고르기 전 = 비목으로
return lb ? "인력" : "재료";
}
return "율";
}
/** 차림에 안 넣는 기호 — 옵션 · 금액 줄이 가진 연결 */
export function hiddenOf(doc: FormulaDoc): Set<string> {
const out = new Set(ownedOf(doc).values());
for (const v of doc.변수 ?? []) if (isOption(v)) out.add(varId(v));
return out;
}
/** 쓰는 차례(입력 먼저) — 기호마다 부르는 기호를 먼저 */
function postOrder(doc: FormulaDoc, roots: string[], uses: Map<string, string[]>): string[] {
const out: string[] = [];
const seen = new Set<string>();
const go = (id: string): void => {
if (seen.has(id)) return;
seen.add(id);
for (const n of uses.get(id) ?? []) go(n);
out.push(id);
};
roots.forEach(go);
symbols(doc).forEach(go);
return out;
}
/** 금액 줄 i 가 (거쳐서) 부르는 기호 */
function reachOf(line: MoneyLine, uses: Map<string, string[]>): Set<string> {
const out = new Set<string>();
const go = (id: string): void => {
if (out.has(id)) return;
out.add(id);
(uses.get(id) ?? []).forEach(go);
};
[...namesIn(line.수량), ...namesIn(line.단가)].forEach(go);
return out;
}
/** 문서 → 줄 쌓기(저장 차림 + 안 놓인 것은 기본 자리) */
export function stackOf(doc: StackDoc): StackItem[] {
const money = doc.금액 ?? [];
const all = new Set(symbols(doc));
const hidden = hiddenOf(doc);
const items: StackItem[] = [];
const where = new Map<number, StackItem>();
const placed = new Set<string>();
const saved = Array.isArray(doc.쌓기) ? doc.쌓기 : [];
const taken = new Set<number>();
for (const s of saved) {
const it: StackItem = { 제목: String(s?.제목 ?? ""), 줄: [] };
if (s?.가나다) it.가나다 = true;
for (const l of Array.isArray(s?.줄) ? s.줄 : []) {
if (l && "금액" in l) {
let i = Number(l.금액);
if (!(money[i] && (money[i].이름 ?? "") === l.이름 && !taken.has(i)))
i = money.findIndex((m, k) => !taken.has(k) && (m.이름 ?? "") === l.이름);
if (i < 0 || taken.has(i)) continue;
taken.add(i);
where.set(i, it);
it.줄.push({ 꼴: "금액", i });
} else if (l && "공식" in l) {
const ids = (Array.isArray(l.공식) ? l.공식 : []).filter(
(x) => all.has(x) && !hidden.has(x) && !placed.has(x),
);
ids.forEach((x) => placed.add(x));
const one: StackLine = { 꼴: "공식", 기호: ids };
if (l.틀) Object.assign(one, { 틀: String(l.틀) }, l.틀꼴 ? { 틀꼴: String(l.틀꼴) } : {});
it.줄.push(one);
} else if (l && "글" in l) it.줄.push({ 꼴: "글", 글: String(l.글 ?? "") });
}
items.push(it);
}
// 안 놓인 금액 줄 — 앞 줄 항목(묶음 같으면) › 같은 제목 항목 › 새 항목
money.forEach((m, i) => {
if (taken.has(i)) return;
const title = String(m.묶음 ?? "");
const prev = where.get(i - 1);
let it = prev && prev.제목 === title ? prev : undefined;
if (it && prev) {
const at = prev.줄.findIndex((x) => x.꼴 === "금액" && x.i === i - 1);
prev.줄.splice(at + 1, 0, { 꼴: "금액", i });
} else {
it = saved.length ? items.find((x) => x.제목 === title) : undefined;
if (!it) items.push((it = { 제목: title, 줄: [] }));
it.줄.push({ 꼴: "금액", i });
}
taken.add(i);
where.set(i, it);
});
// 안 놓인 기호 — 처음 쓰는 금액 줄 항목의 첫 공식 묶음(없으면 맨 앞에 새로) · 아무도 안 쓰면 첫 항목
const uses = usesOf(doc);
const reach = money.map((m) => reachOf(m, uses));
const order = postOrder(doc, doc.결과 ? [doc.결과] : [], uses);
for (const id of order) {
if (!all.has(id) || hidden.has(id) || placed.has(id)) continue;
const i = reach.findIndex((r) => r.has(id));
let it = i >= 0 ? where.get(i) : items[0];
if (!it) items.push((it = { 제목: "", 줄: [] }));
let block = it.줄.find((x) => x.꼴 === "공식");
if (!block) it.줄.unshift((block = { 꼴: "공식", 기호: [] }));
(block as { 기호: string[] }).기호.push(id);
placed.add(id);
}
return items;
}
const saveOf = (items: StackItem[], at: (i: number) => number, money: MoneyLine[]): SaveItem[] =>
items.map((it) => ({
...(it.제목 ? { 제목: it.제목 } : {}),
...(it.가나다 ? { 가나다: true } : {}),
줄: it.줄.map((l): SaveLine => {
if (l.꼴 === "금액") return { 금액: at(l.i), 이름: money[l.i]?.이름 ?? "" };
if (l.꼴 === "글") return { 글: l.글 };
return {
공식: [...l.기호],
...(l.틀 ? { 틀: l.틀 } : {}),
...(l.틀꼴 ? { 틀꼴: l.틀꼴 } : {}),
};
}),
}));
/** 줄 쌓기 → 문서 — 금액 = 쌓은 차례(안 쌓인 금액 줄은 지움) · 차림이 기본과 다를 때만 `쌓기` */
export function docOf(doc: StackDoc, items: StackItem[]): StackDoc {
const money = doc.금액 ?? [];
const order: number[] = [];
for (const it of items)
for (const l of it.줄)
if (l.꼴 === "금액" && money[l.i] && !order.includes(l.i)) order.push(l.i);
const out = clone(doc);
delete out.쌓기;
if (doc.금액 !== undefined || order.length) out.금액 = order.map((i) => clone(money[i]));
const pos = new Map(order.map((i, k) => [i, k]));
const live = new Set(symbols(out));
const kept = items.map((it) => ({
...it,
줄: it.줄
.filter((l) => l.꼴 !== "금액" || pos.has(l.i))
.map((l) => (l.꼴 === "공식" ? { ...l, 기호: l.기호.filter((x) => live.has(x)) } : l)),
}));
const save = saveOf(kept, (i) => pos.get(i)!, money);
const plain = saveOf(stackOf(out), (i) => i, out.금액 ?? []);
if (JSON.stringify(save) !== JSON.stringify(plain)) out.쌓기 = save;
return out;
}
/** 줄 쌓기가 못 고치는 문서 칸(보존은 됨 · 수식 탭에서 고침) */
export function notesOf(doc: FormulaDoc): string[] {
const out: string[] = [];
if (doc.단계?.length) out.push("단계");
if (doc.조정 && Object.keys(doc.조정).length) out.push("조정");
const money = doc.금액 ?? [];
if (money.some((l) => l.번지)) out.push("번지");
if (money.some((l) => l.합밖)) out.push("합밖");
if (money.some((l) => l.자리)) out.push("줄 자리");
if (money.some((l) => l.기호)) out.push("금액 줄 기호");
return out;
}
// ── 고침 ───────────────────────────────────────────────────────────────
/** 안 쓰는 새 기호 — `기호_아래N` */
export function freshId(doc: FormulaDoc, sign: string, low = ""): string {
const have = new Set(symbols(doc));
if (low && !have.has(symId(sign, low))) return symId(sign, low);
for (let n = 1; ; n++) {
const id = symId(sign, `${low}${n}`);
if (!have.has(id)) return id;
}
}
export function newVar(id: string, link: DocVar["연결"], name = ""): DocVar {
const [기호, 아래, 위] = splitId(id);
return { 기호, 아래, 위, 이름: name, 단위: "", 연결: link };
}
/** 기호 지움 — 변수 · 식 줄(다른 식이 부르면 「정의 필요」 표지 · 오류 없음) */
export function dropSyms(doc: FormulaDoc, ids: string[]): void {
const gone = new Set(ids);
doc.변수 = (doc.변수 ?? []).filter((v) => !gone.has(varId(v)));
doc.식 = (doc.식 ?? []).filter((l) => !gone.has(String(l.기호 ?? "")));
}
/** 공식 줄 값 · 식 고침 — 수 = 숫자 연결 · 식 = 식 줄 */
export function setRow(doc: FormulaDoc, id: string, text: string): void {
const t = text.trim().replace(/,/g, "");
const n = nodeOf(t);
const plain = n && (n[0] === "num" || (n[0] === "neg" && n[1][0] === "num"));
let v = (doc.변수 ?? []).find((x) => varId(x) === id);
if (plain || !t) {
doc.식 = (doc.식 ?? []).filter((l) => l.기호 !== id);
if (!v) (doc.변수 ??= []).push((v = newVar(id, { 갈래: "숫자" })));
const value = t ? Number(t) : null;
v.연결 = { 갈래: "숫자", 값: Number.isFinite(value) ? value : t };
return;
}
if (v && v.연결?.갈래 !== "식") v.연결 = { 갈래: "식" };
const line = (doc.식 ?? []).find((l) => l.기호 === id);
if (line) line.식 = text.trim();
else (doc.식 ??= []).push({ 기호: id, 식: text.trim() });
}
/** 기호 이름 바꿈 — 문서 전부(`rename`) + 차림 */
export function renameSym(doc: StackDoc, old: string, now: string): void {
const [s, lo, up] = splitId(now);
rename(doc, old, s, lo, up);
for (const it of doc.쌓기 ?? [])
for (const l of it.줄) if ("공식" in l) l.공식 = l.공식.map((x) => (x === old ? now : x));
}
/** 공식 묶음 꼴 — 틀과 견줌(이름 · 식 · 연결 갈래 · 숫자 값은 안 봄) */
export function shapeOf(doc: FormulaDoc, ids: string[]): string {
const vars = new Map((doc.변수 ?? []).map((v) => [varId(v), v]));
const lines = new Map((doc.식 ?? []).map((l) => [String(l.기호 ?? ""), l.식]));
return JSON.stringify(
ids.map((id) => {
const t = lines.get(id);
const n = t === undefined ? null : nodeOf(t);
return [id, n ? textOf(n) : t !== undefined ? String(t) : (vars.get(id)?.연결?.갈래 ?? "식")];
}),
);
}
/** 틀 펼침 — 다른 문서(틀 · 단가산출 결과)의 기호 · 식을 이 문서로 옮김(겹치는 기호 = 아래첨자 끝 수)
* `target` = 결과를 받을 이 문서 기호(단가산출 연결 → 식) · 없으면 결과 기호 그대로 · 돌려줌 = 옮긴 기호(입력 먼저) */
export function unfold(doc: FormulaDoc, src: FormulaDoc, target?: string): string[] {
const sub = clone(src);
const result = sub.결과 ?? "";
const mine = new Set(symbols(doc).filter((x) => x !== target));
const own = new Set(symbols(sub));
const taken = new Set([...mine, ...own]);
for (const id of symbols(sub)) {
if (id === result && target) continue;
if (!mine.has(id) && id !== target) continue;
const [s, lo, up] = splitId(id);
let n = 2;
while (taken.has(symId(s, `${lo}${n}`, up))) n++;
rename(sub, id, s, `${lo}${n}`, up);
taken.add(symId(s, `${lo}${n}`, up));
}
const given = target ? ((doc.변수 ?? []).find((v) => varId(v) === target)?.연결?.입력 ?? {}) : {};
let resultId = sub.결과 ?? "";
if (target && resultId && resultId !== target) {
const [s, lo, up] = splitId(target);
rename(sub, resultId, s, lo, up);
resultId = target;
}
const keys = new Set((doc.변수 ?? []).map((v) => v.열쇠).filter(Boolean));
const moved: string[] = [];
for (const v of sub.변수 ?? []) {
const id = varId(v);
if (id === resultId && target) continue;
if (v.열쇠 && v.열쇠 in given) {
v.연결 = { 갈래: "식" };
(sub.식 ??= []).push({ 기호: id, 식: String(given[v.열쇠]) });
delete v.열쇠;
} else if (v.열쇠 && keys.has(v.열쇠)) delete v.열쇠;
(doc.변수 ??= []).push(v);
}
for (const l of sub.식 ?? []) (doc.식 ??= []).push(l);
if (target) {
const t = (doc.변수 ?? []).find((v) => varId(v) === target);
const r = (src.변수 ?? []).find((v) => varId(v) === src.결과);
if (t) {
t.연결 = { 갈래: "식" };
if (!t.단위) t.단위 = r?.단위 || src.결과단위 || "";
}
}
const uses = usesOf(sub);
for (const id of postOrder(sub, resultId ? [resultId] : [], uses))
if (
symbols(sub).includes(id) &&
!moved.includes(id) &&
!isOption(sub.변수?.find((v) => varId(v) === id))
)
moved.push(id);
return moved;
}
/** 단가산출 연결이 넘길 입력 값 — 부른 쪽 값으로 셈(펼침이 같은 계산기로 그 문서를 셈) */
export function subInputs(
link: Link | undefined,
vals: Record<string, unknown>,
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [name, t] of Object.entries(link?.입력 ?? {})) {
const n = nodeOf(t);
const v = n ? evalNode(n, vals) : undefined;
if (v !== undefined) out[name] = v;
}
return out;
}
+26
View File
@@ -10,6 +10,7 @@
"dependencies": {
"@kninnug/constrainautor": "^4.1.0",
"delaunator": "^5.1.0",
"katex": "^0.19.1",
"maplibre-gl": "^5.24.0",
"three": "^0.185.0"
},
@@ -540,6 +541,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/commander": {
"version": "15.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-15.1.0.tgz",
"integrity": "sha512-cgnQ88TRrWcC7pVq46RzJVRMktu3W2RcbnoidnfBsl1bmDCBwJS/udcIZpKoCrbAYYaCqsz1yZCVIgbIaU3ddQ==",
"license": "MIT",
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/delaunator": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
@@ -617,6 +627,22 @@
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
"license": "MIT"
},
"node_modules/katex": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.19.1.tgz",
"integrity": "sha512-jG8uKntjSEcm2EpQOh0XturpPuuC9r3Sq5HLHdsFdWZT27E672SF19QEi+of0/YeOy15lDgRhKvr5614uRrEfA==",
"funding": [
"https://opencollective.com/katex",
"https://github.com/sponsors/katex"
],
"license": "MIT",
"dependencies": {
"commander": "^15.0.0"
},
"bin": {
"katex": "cli.js"
}
},
"node_modules/kdbush": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
+1
View File
@@ -6,6 +6,7 @@
"dependencies": {
"@kninnug/constrainautor": "^4.1.0",
"delaunator": "^5.1.0",
"katex": "^0.19.1",
"maplibre-gl": "^5.24.0",
"three": "^0.185.0"
},
@@ -0,0 +1,113 @@
/* 804-2 줄 쌓기 헬퍼 — common_util_line_stack.ts · 계산기 · 캐시를 그 자리에서 트랜스파일해 Node 로 돌림.
* stdin = {할일, 문서들 [{문서, 연결?}]} · stdout = 할일마다 답 목록 */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const cache = new Map();
function load(at) {
if (cache.has(at)) return cache.get(at);
const js = ts.transpileModule(fs.readFileSync(at, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(at, box.exports);
const need = (p) =>
p.startsWith(".") ? load(path.resolve(path.dirname(at), `${p}.ts`)) : require(p);
new Function("exports", "module", "require", js)(box.exports, box, need);
return box.exports;
}
const util = (name) => load(path.join(__dirname, "..", "..", "common_util", `${name}.ts`));
const ls = util("common_util_line_stack");
const calc = util("common_util_formula_doc_calc");
const dc = util("common_util_formula_doc_cache");
const money = (a) => ({ 노무비: a.노무비, 재료비: a.재료비, 경비: a.경비, 계: a.계, 결과: a.결과 });
/** 왕복 — 문서 → 줄 → 문서 · 덮음(모든 금액 줄 한 번 · 차림 기호 한 번) · 차림 고침 뒤 다시 읽어 같음 */
function roundTrip({ 문서 }) {
const items = ls.stackOf(문서);
const back = ls.docOf(문서, items);
const ms = items.flatMap((it) => it.줄.filter((l) => l.꼴 === "금액").map((l) => l.i));
const syms = items.flatMap((it) => it.줄.filter((l) => l.꼴 === "공식").flatMap((l) => l.기호));
const hidden = ls.hiddenOf(문서);
const want = ls.symbols(문서).filter((x) => !hidden.has(x));
// 차림 고침 — 항목마다 글 줄 · 첫 항목 가나다 · 맨 앞 새 항목 제목
const edited = JSON.parse(JSON.stringify(items));
edited.forEach((it, k) => it.줄.push({ 꼴: "글", 글: `< 근거 ${k} >` }));
if (edited[0]) edited[0].가나다 = true;
edited.unshift({ 제목: "시험 머리", 줄: [{ 꼴: "글", 글: "※ 메모" }] });
const laid = ls.docOf(문서, edited);
const again = ls.stackOf(laid);
return {
같음: JSON.stringify(back) === JSON.stringify(문서),
쌓기: "쌓기" in back,
금액: ms.length === (문서.금액 || []).length && new Set(ms).size === ms.length,
기호:
syms.length === want.length &&
new Set(syms).size === syms.length &&
want.every((x) => syms.includes(x)),
차림: JSON.stringify(again) === JSON.stringify(edited),
차림문서: laid,
못: ls.notesOf(문서),
};
}
/** 셈 — 연결 기록만으로(화면과 같은 길) · 기계 · 단가산출 펼침 셈 */
function money_({ 문서, 연결 }) {
const c = dc.createDocCache();
c.merge(연결);
const a = calc.calcDoc(문서, c.links, {});
const subs = [];
(문서.금액 || []).forEach((l, i) => {
const kind = ls.kindOf(문서, i);
if (kind !== "기계" && kind !== "단가산출") return;
const s = ls.priceSym(l);
const link = (문서.변수 || []).find((v) => ls.symbols({ 변수: [v] })[0] === s)?.연결;
const sub = c.links.doc(link.키);
const ins = ls.subInputs(link, a.값 || {});
const got = calc.calcDoc(sub, c.links, ins);
subs.push({ i, 갈래: kind, 키: link.키, 입력: ins, ...money(got), 값: (a.값 || {})[s] });
});
return { ...money(a), 펼침: subs, 누락: c.takeMissing() };
}
/** 틀 펼침 — 단가산출 결과 연결 기호를 이 문서 식으로 옮김 → 셈 같음 · 기호 없으면 그런 기호 전부(하나씩)
* 고침 = 펼친 뒤 [{기호, 글}](setRow) · 지움 = 펼친 뒤 지울 기호(dropSyms) */
function unfold({ 문서, 연결, 기호, 고침, 지움 }) {
const c = dc.createDocCache();
c.merge(연결);
const before = calc.calcDoc(문서, c.links, {});
const idOf = (v) => ls.symbols({ 변수: [v] })[0];
const targets = 기호
? [기호]
: (문서.변수 || [])
.filter((v) => v.연결?.갈래 === "단가산출" && v.연결.키 && !v.연결.키.includes("{"))
.filter((v) => c.links.doc(v.연결.키)?.결과)
.map(idOf);
return targets.map((id) => {
const link = 문서.변수.find((v) => idOf(v) === id).연결;
const doc = JSON.parse(JSON.stringify(문서));
const moved = ls.unfold(doc, c.links.doc(link.키), id);
const after = calc.calcDoc(doc, c.links, {});
for (const g of 고침 || []) ls.setRow(doc, g.기호, g.글);
if (지움) ls.dropSyms(doc, 지움);
const edited = calc.calcDoc(doc, c.links, {});
const items = ls.stackOf(doc);
return {
기호: id,
전: money(before),
후: money(after),
고친: money(edited),
표지: (edited.표지 || []).map((m) => m.갈래),
옮김: moved,
틀꼴: ls.shapeOf(doc, moved),
왕복: JSON.stringify(ls.docOf(doc, items)) === JSON.stringify(doc),
문서: doc,
};
});
}
const input = JSON.parse(fs.readFileSync(0, "utf8"));
const job = { 왕복: roundTrip, 셈: money_, 펼침: unfold }[input.할일];
process.stdout.write(JSON.stringify(input.문서들.map(job)));
+229
View File
@@ -0,0 +1,229 @@
# -*- coding: utf-8 -*-
"""PLAN 804-2 — 단가산출 「줄 쌓기」 탭(요구 670~677) · 수식 문서 ↔ 줄 쌓기 한 벌.
① 왕복 1,518 전수 — 문서 → 줄 → 문서 같음 · 금액 줄 · 기호 모두 한 번 ·
차림 고침(항목 · 글 줄 · 가나다) 다시 읽어 같음 · 차림 칸 `쌓기` 든 문서도 서버 셈 같음
② 금액 — 화면 길(연결 기록 + TS 계산기) = 서버 정본 셈(1,518 전수)
③ 기계 펼침 — 시간당 경비 문서를 넘긴 입력으로 셈 = 부른 쪽 단가 묶음(원 단위) ·
`M02_Machine_Hourly.hourly`(같은 로직 · 같은 입력) 와 같음
④ 틀 풀기 — 단가산출 결과 연결을 이 문서 식으로 옮김 = 금액 같음(전수) ·
Cm 고침 → 값 · 지움 = 표지(오류 없음)
⑤ 저장 — `쌓기` 든 문서 저장(임시 폴더) · 다시 읽어 같음 · 정본 폴더 안 바뀜
⑥ 화면 이음 — M01 · M02 보기 셋 · 같은 길 · 같은 캐시 열쇠 · KaTeX 미리 묶기 뺌
"""
from __future__ import annotations
import copy
import importlib
import json
import os
import shutil
import subprocess
from decimal import Decimal
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from M01_MasterData import M01_MasterData_Doc as doc_screen
from M01_MasterData import M01_MasterData_Router as router_module
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Doc_Copy as dc
from M02_MasterTemplete import M02_Machine_Hourly as mh
mr = importlib.import_module("master_doc_record") # M01 store 가 scripts 길을 둔 뒤
ROOT = Path(__file__).resolve().parents[2]
HERE = os.path.dirname(os.path.abspath(__file__))
COSTS = ("노무비", "재료비", "경비", "계")
def _ts(job: str, cases: list[dict]) -> list:
proc = subprocess.run(
["node", os.path.join(HERE, "helper_804_2_line_stack.cjs")],
input=json.dumps(dc.ml._norm({"할일": job, "문서들": cases}), ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
)
assert proc.returncode == 0, proc.stderr[-3000:]
return json.loads(proc.stdout)
@pytest.fixture(scope="module")
def world():
docs = dc.folder()
links = dc.ml.Links(dc.skc.whole_(), docs)
cases, py = [], {}
for key, doc in docs.items():
rec = mr.Rec(links)
rec.prefetch(doc)
py[key] = dc.mc.calc(doc, rec)
cases.append({"문서": doc, "연결": rec.out()})
return {"docs": docs, "links": links, "py": py, "cases": dict(zip(docs, cases))}
def _num(v):
return None if v is None else float(v)
def test_왕복_전수_1518(world):
docs = world["docs"]
assert len(docs) >= 1518
got = _ts("왕복", [{"문서": d} for d in docs.values()])
bad = {f: [k for k, g in zip(docs, got) if not g[f]] for f in ("같음", "금액", "기호", "차림")}
assert bad == {"같음": [], "금액": [], "기호": [], "차림": []}
assert not any(g["쌓기"] for g in got) # 기본 차림 = 문서에 `쌓기` 안 씀
# 차림 칸이 든 문서 — 서버 셈이 같음(계산기는 차림을 안 봄)
for key, g in list(zip(docs, got))[::7]:
laid = json.loads(json.dumps(g["차림문서"]), parse_float=Decimal, parse_int=Decimal)
assert "쌓기" in laid
a, b = dc.mc.calc(docs[key], world["links"]), dc.mc.calc(laid, world["links"])
assert [_num(a.get(f)) for f in (*COSTS, "결과")] == [
_num(b.get(f)) for f in (*COSTS, "결과")
], key
# 못 고치는 칸 목록(보존됨) — 지금 문서엔 금액 줄 기호만
notes = {n for g in got for n in g["못"]}
assert notes <= {"단계", "조정", "번지", "합밖", "줄 자리", "금액 줄 기호"}
def test_금액_화면길_서버정본_전수(world):
keys = list(world["docs"])
got = _ts("셈", [world["cases"][k] for k in keys])
diff = [
(k, f)
for k, g in zip(keys, got)
for f in (*COSTS, "결과")
if _num(world["py"][k].get(f)) != (None if g.get(f) is None else pytest.approx(g[f]))
]
assert diff == [] and not any(g["누락"] for g in got)
world["셈"] = dict(zip(keys, got))
def _hourly_inputs(logic_key: str) -> dict:
"""hourly 가 고른 로직 · 그 로직의 기본 입력(기계 밖)."""
rows = {r["키"]: r for r in mh._family()}
row = rows.get(logic_key)
if not row:
return {}
return {
i["이름"]: str((i.get("고르기") or [""])[0])
for i in row.get("입력") or []
if i.get("이름") != "기계"
}
def test_기계펼침_원단위_hourly(world):
got = world.get("셈") or dict(zip(world["docs"], _ts("셈", list(world["cases"].values()))))
eq = m01.loaded()[1].index["EQ"]
by_code: dict[str, list[str]] = {}
for key, row in eq.items():
by_code.setdefault(str(row.get("원문번호")), []).append(key)
lines = same_parent = same_hourly = 0
memo: dict[str, dict] = {}
family = mh._family()
for k, g in got.items():
for s in g["펼침"]:
if s["갈래"] != "기계":
continue
lines += 1
# 펼친 셈 = 부른 쪽 단가 묶음(화면 「노무비 : 59,020」 의 그 수)
assert all(float(s["값"][f]) == float(s[f]) for f in COSTS), (k, s["키"])
same_parent += 1
code = str(s["입력"].get("기계"))
for key in by_code.get(code, []):
if key not in memo: # 같은 기계는 한 번만 셈
memo[key] = mh.hourly(key)
h = memo[key]
if not h.get("ok"):
continue
pick = min(
(r for r in family if code in [str(c) for c in mh._codes(r)]),
key=mh._rank,
)
want = _hourly_inputs(pick["키"])
given = {n: str(v) for n, v in s["입력"].items() if n != "기계"}
if pick["키"] != s["키"] or any(want.get(n) != v for n, v in given.items()):
break # 다른 로직 · 다른 입력(조종원 직종 등)을 넘긴 줄
assert all(float(h[f]) == float(s[f]) for f in COSTS), (k, key, h, s)
same_hourly += 1
break
assert lines >= 1000 and same_parent == lines and same_hourly >= 800
def test_틀풀기_전수_금액같음_Cm고침(world):
cases = list(world["cases"].values())
got = [x for g in _ts("펼침", cases) for x in g]
assert len(got) >= 20 # 단가산출 결과 연결(굴착기 Q 등) 전부
bad = [g["기호"] for g in got if g["전"] != g["후"] or not g["왕복"]]
assert bad == []
one = _ts(
"펼침",
[{**world["cases"]["GF000099"], "기호": "Q_작업", "고침": [{"기호": "Cm", "글": "20"}]}],
)[0][0]
assert one["전"]["계"] == 5286 and one["후"]["계"] == 5286
assert one["옮김"][-1] == "Q_작업" and {"q", "K", "f", "E", "Cm"} <= set(one["옮김"])
assert one["고친"]["계"] == 5434 # Cm 18 → 20 = Q 58.212 → 52.391
gone = _ts("펼침", [{**world["cases"]["GF000099"], "기호": "Q_작업", "지움": ["Cm"]}])[0][0]
assert gone["고친"]["계"] is None and "정의 필요" in gone["표지"] # 지워도 오류 없음
@pytest.fixture
def client(tmp_path: Path, monkeypatch):
for path in doc_screen.folder().glob("수식_*.json"):
shutil.copy(path, tmp_path / path.name)
monkeypatch.setattr(doc_screen, "folder", lambda: tmp_path)
doc_screen._memo.clear()
app = FastAPI()
app.include_router(router_module.router)
yield TestClient(app)
doc_screen._memo.clear()
def test_저장_쌓기_다시읽어같음(client, tmp_path):
read = client.get("/api/m01/logic/doc", params={"key": "GF000099"}).json()
doc = copy.deepcopy(read["문서"])
got = _ts("왕복", [{"문서": doc}])[0]
laid = got["차림문서"]
res = client.post("/api/m01/logic/doc/save", json={"문서": laid})
assert res.status_code == 200, res.text
assert res.json()["셈"]["계"] == read["셈"]["계"] == 5286
again = client.get("/api/m01/logic/doc", params={"key": "GF000099"}).json()
assert (
again["문서"]["쌓기"] == laid["쌓기"] and _ts("왕복", [{"문서": again["문서"]}])[0]["같음"]
)
text = (tmp_path / res.json()["파일"]).read_text(encoding="utf-8")
assert '"쌓기": [' in text and "< 근거 0 >" in text
real = (ROOT / "resources/master_data/수식문서" / res.json()["파일"]).read_text(
encoding="utf-8"
)
assert "< 근거 0 >" not in real
def _src(rel: str) -> str:
return (ROOT / rel).read_text(encoding="utf-8")
def test_화면_이음():
lab = _src("M01_MasterData/M01_MasterData_UI_LogicLab.ts")
assert 'createStackScreen({ base: "/m01/logic/doc", admin, cache: "m01_doc_drafts" })' in lab
assert (
"stack: stack.root" in lab
and "equation.canLeave().then((ok) => ok && stack.canLeave())" in lab
)
assert '{ value: "stack", label: lt("Tab") }' in _src(
"M01_MasterData/M01_MasterData_UI_LogicLab_View.ts"
)
assert '[data-view="stack"]' in _src("M01_MasterData/M01_MasterData_UI_LogicLab_Style.css")
tabs = _src("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts")
assert 'cache: "m02_sk_doc_drafts"' in tabs and tabs.count("skDocPath(opt.scope, k, p)") == 2
vite = _src("vite.config.ts")
assert 'exclude: ["0_old", "katex"]' in vite and "katex/dist/katex.mjs" in vite
pkg = json.loads(_src("config/package.json"))
assert "katex" in pkg["dependencies"]
lic = json.loads(_src("config/node_modules/katex/package.json"))["license"]
assert lic == "MIT"
for f in (ROOT / "A00_Common").glob("line_stack*.ts"):
assert len(f.read_text(encoding="utf-8").splitlines()) <= 700, f.name
+1
View File
@@ -31,6 +31,7 @@
"three/*": ["config/node_modules/@types/three/*"],
"maplibre-gl": ["config/node_modules/maplibre-gl"],
"delaunator": ["config/node_modules/delaunator"],
"katex": ["config/node_modules/katex"],
"@kninnug/constrainautor": ["config/node_modules/@kninnug/constrainautor"],
}
},
+3 -1
View File
@@ -29,6 +29,7 @@ export default {
three: resolve(__dirname, "./config/node_modules/three"),
"maplibre-gl": resolve(__dirname, "./config/node_modules/maplibre-gl"),
delaunator: resolve(__dirname, "./config/node_modules/delaunator"),
katex: resolve(__dirname, "./config/node_modules/katex/dist/katex.mjs"),
"@kninnug/constrainautor": resolve(
__dirname,
"./config/node_modules/@kninnug/constrainautor",
@@ -77,6 +78,7 @@ export default {
target: "es2022",
},
optimizeDeps: {
exclude: ["0_old"],
// katex 는 미리 묶기(rolldown)가 글자 규칙의 서로게이트 범위를 깨뜨림(`\text{…}` 안 그려짐) → 원본 ESM 그대로
exclude: ["0_old", "katex"],
},
};