/* ============================================================================= * 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; }