From c11baa441ccf189092073d5c25b125e1079a6835 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 8 Nov 2026 00:34:29 +0900 Subject: [PATCH] =?UTF-8?q?feat(804-2):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=E3=80=8C=EC=A4=84=20=EC=8C=93=EA=B8=B0=E3=80=8D=20?= =?UTF-8?q?=EC=83=88=20=ED=83=AD=20=E2=80=94=20M01=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=20=EA=B8=B0=EC=B4=88=20=C2=B7=20M02=20?= =?UTF-8?q?=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=20=C2=B7=20=EA=B8=B0?= =?UTF-8?q?=EA=B3=84=20=EC=8B=9C=EA=B0=84=EB=8B=B9=20=EA=B2=BD=EB=B9=84=20?= =?UTF-8?q?=EC=88=98=EC=8B=9D=20=ED=8E=BC=EC=B9=A8=20=C2=B7=20KaTeX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공용 화면 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 --- A00_Common/line_stack.css | 197 ++++++++ A00_Common/line_stack_edit.ts | 377 +++++++++++++++ A00_Common/line_stack_out.ts | 344 ++++++++++++++ A00_Common/line_stack_rows.ts | 253 +++++++++++ A00_Common/line_stack_screen.ts | 411 +++++++++++++++++ A00_Common/line_stack_tex.ts | 139 ++++++ A00_Common/line_stack_text.ts | 64 +++ M01_MasterData/M01_MasterData_UI_LogicLab.ts | 22 +- .../M01_MasterData_UI_LogicLab_Detail.ts | 3 + .../M01_MasterData_UI_LogicLab_Style.css | 1 + .../M01_MasterData_UI_LogicLab_View.ts | 16 +- .../M02_MasterTemplete_Sangeun_Tabs.ts | 41 +- common_util/common_util_line_stack.ts | 429 ++++++++++++++++++ config/package-lock.json | 26 ++ config/package.json | 1 + resources/tester/helper_804_2_line_stack.cjs | 113 +++++ resources/tester/test_804_2_line_stack.py | 229 ++++++++++ tsconfig.json | 1 + vite.config.ts | 4 +- 19 files changed, 2657 insertions(+), 14 deletions(-) create mode 100644 A00_Common/line_stack.css create mode 100644 A00_Common/line_stack_edit.ts create mode 100644 A00_Common/line_stack_out.ts create mode 100644 A00_Common/line_stack_rows.ts create mode 100644 A00_Common/line_stack_screen.ts create mode 100644 A00_Common/line_stack_tex.ts create mode 100644 A00_Common/line_stack_text.ts create mode 100644 common_util/common_util_line_stack.ts create mode 100644 resources/tester/helper_804_2_line_stack.cjs create mode 100644 resources/tester/test_804_2_line_stack.py diff --git a/A00_Common/line_stack.css b/A00_Common/line_stack.css new file mode 100644 index 000000000..aa1af24c2 --- /dev/null +++ b/A00_Common/line_stack.css @@ -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; +} diff --git a/A00_Common/line_stack_edit.ts b/A00_Common/line_stack_edit.ts new file mode 100644 index 000000000..e40a4a2f1 --- /dev/null +++ b/A00_Common/line_stack_edit.ts @@ -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; + 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 = { + 인력: "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["연결"] & 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)[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)[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): 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({ 제목: "", 줄: [] }))), + ], + }); +} diff --git a/A00_Common/line_stack_out.ts b/A00_Common/line_stack_out.ts new file mode 100644 index 000000000..77e0b1fd5 --- /dev/null +++ b/A00_Common/line_stack_out.ts @@ -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>; +interface Row { + h: (string | HTMLElement)[]; + cols?: Cols; + cls?: string; + won?: boolean; + depth: number; +} +export interface OutHooks { + cache: DocCache; + /** 펼친 자리(`부모/기호`) */ + open: Set; + toggle: (at: string) => void; + /** 캐시에 없는 연결 — 뒤에서 서버에 물음 */ + miss: () => void; +} + +const COST_SHOW: Record = { 노무비: "노무비", 재료비: "재료비", 경비: "경 비" }; +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): 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; + 금액?: 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 = { 노무비: [], 재료비: [], 경비: [] }; + 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 = { 노무비: 0, 재료비: 0, 경비: 0 }; + const own: Record = { 노무비: [], 재료비: [], 경비: [] }; + 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; +} diff --git a/A00_Common/line_stack_rows.ts b/A00_Common/line_stack_rows.ts new file mode 100644 index 000000000..8964c9dd9 --- /dev/null +++ b/A00_Common/line_stack_rows.ts @@ -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 = (v: T): T => JSON.parse(JSON.stringify(v)) as T; +type Block = Extract; + +/** 틀 넣기 — 새 공식 묶음 줄 */ +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 = { + 표: "표", + 요소: "요소", + 단가산출: "단가산출", + 선택: "선택", + 입력: "입력", +}; + +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; + 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)[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]; +} diff --git a/A00_Common/line_stack_screen.ts b/A00_Common/line_stack_screen.ts new file mode 100644 index 000000000..2e83eb888 --- /dev/null +++ b/A00_Common/line_stack_screen.ts @@ -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; +interface Loaded { + 문서: StackDoc; + 셈: DocAnswer; + 연결: DocRecord; + 새?: boolean; + 끊음?: Docs; + 사본?: CopyMark; + 판?: string; +} + +const TPL_KEY = "lineStack.myTemplates"; +const clone = (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 = {}; + let cut: Docs = {}; + let mark: CopyMark | undefined; + let version = ""; + let seq = 0; + let asked = ""; + let ctl: AbortController | null = null; + const opened = new Set(); // 산출근거 펼친 자리 + const blocks = new Set(); // 풀린 공식 묶음 + + const call = async (path: string, body?: unknown, signal?: AbortSignal): Promise => { + 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 => { + try { + return JSON.parse(sessionStorage.getItem(opt.cache) ?? "{}") as Record; + } 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 => { + const out: Record = {}; + 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 => { + 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("/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 => { + if (!doc || !dirty()) return void draw(); + doc = JSON.parse(saved) as StackDoc; + keep(); + recalc(); + draw(); + }; + + /** 문서 열기 — 서버 · 캐시에서 다시 읽음(수식 탭 고침 · 저장을 이어 받음) */ + const open = async (next: string): Promise => { + 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(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 => { + 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) : [] }), + }; +} diff --git a/A00_Common/line_stack_tex.ts b/A00_Common/line_stack_tex.ts new file mode 100644 index 000000000..806e057b3 --- /dev/null +++ b/A00_Common/line_stack_tex.ts @@ -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 = { + "+": "+", + "-": "-", + "*": "\\times", + "/": "\\div", + "<": "<", + "<=": "\\le", + ">": ">", + ">=": "\\ge", + "==": "=", + "!=": "\\ne", + 그리고: "\\;\\text{그리고}\\;", + 또는: "\\;\\text{또는}\\;", +}; +const PREC: Record = { + 또는: 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; +} diff --git a/A00_Common/line_stack_text.ts b/A00_Common/line_stack_text.ts new file mode 100644 index 000000000..4b48c13ee --- /dev/null +++ b/A00_Common/line_stack_text.ts @@ -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]; diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index a4037f5b3..9d7efe094 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -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 => 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 => { - 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"); diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts index d60905443..274e98953 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts @@ -38,6 +38,8 @@ export interface DetailContext extends HoContext { cover?: { ho: HTMLElement; open?: ReturnType }; /** 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; // 기본정보 칸은 새로 만들어지므로 이름표로 다시 찾음 diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css index b729c2d95..7e56c29d3 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css @@ -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; } diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_View.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_View.ts index 28b301364..cdac143e4 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_View.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_View.ts @@ -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 = (node: T, view: LabView): T => { @@ -20,17 +21,24 @@ export const block = (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") }) }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts index 643b4ba7f..2e56b5060 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts @@ -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()), + }; } diff --git a/common_util/common_util_line_stack.ts b/common_util/common_util_line_stack.ts new file mode 100644 index 000000000..e81459634 --- /dev/null +++ b/common_util/common_util_line_stack.ts @@ -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 = (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 { + const out = new Map(); + 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 { + const count = new Map(); + 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(); + (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 { + 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[] { + const out: string[] = []; + const seen = new Set(); + 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): Set { + const out = new Set(); + 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(); + const placed = new Set(); + const saved = Array.isArray(doc.쌓기) ? doc.쌓기 : []; + const taken = new Set(); + 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, +): Record { + const out: Record = {}; + 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; +} diff --git a/config/package-lock.json b/config/package-lock.json index 93c132f2e..5754596f3 100644 --- a/config/package-lock.json +++ b/config/package-lock.json @@ -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", diff --git a/config/package.json b/config/package.json index c05fe0f02..d5e46adc4 100644 --- a/config/package.json +++ b/config/package.json @@ -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" }, diff --git a/resources/tester/helper_804_2_line_stack.cjs b/resources/tester/helper_804_2_line_stack.cjs new file mode 100644 index 000000000..3ff0173a4 --- /dev/null +++ b/resources/tester/helper_804_2_line_stack.cjs @@ -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))); diff --git a/resources/tester/test_804_2_line_stack.py b/resources/tester/test_804_2_line_stack.py new file mode 100644 index 000000000..fdf6b3da3 --- /dev/null +++ b/resources/tester/test_804_2_line_stack.py @@ -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 diff --git a/tsconfig.json b/tsconfig.json index 2964abb60..6de93c2cb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"], } }, diff --git a/vite.config.ts b/vite.config.ts index 13b980d05..7fd489fa3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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"], }, };