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:
@@ -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;
|
||||
}
|
||||
@@ -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({ 제목: "", 줄: [] }))),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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) : [] }),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
@@ -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()),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Generated
+26
@@ -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",
|
||||
|
||||
@@ -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)));
|
||||
@@ -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
|
||||
@@ -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
@@ -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"],
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user