/* ============================================================================= * line_stack_rows.ts * 줄 쌓기 공식 묶음(PLAN 804-2 · 요구 675) — 「이름 = 값」 · 「이름 = 식」 줄 묶음 · 틀 이름표 · [식으로 풀기] · [틀로 저장] * 보임 = 값 줄은 값 칸(연결 값을 고쳐 적으면 숫자 = 고정값 · 요구 664) · 식 줄은 「이름 = 식 = 값」 글 · 단가산출 결과 기호 = [식으로 풀기] * 고침(804-7 · 요구 687-14) = [식 고치기] 걷음 — 이름 · 식 글을 누르면 그 줄만 그 자리에서 이름 · 식 · 단위 칸 + 지움 · * 바꾸면 닫힘 · 머리 [+] = 식 줄 더함 — 이름 바꿈 = 문서 전부(`rename`) · 식 = 식 줄 · 수 = 숫자 연결 * 만약 안 탄 갈래 기호(`hide` · 요구 687-10) = 안 보임(식 글은 그대로) * 틀 = 단가산출 결과 문서(굴착기 Q 등)를 이 문서 식으로 옮김(`unfold`) — 꼴이 바뀌면 「내 틀 (틀 이름 에서 변경)」 · * 빈 틀 = [찾아 넣기](M01 단가산출 결과 — 작업량 등) · [틀로 저장] · 내 틀 목록 걷음(요구 685) · 표 값만 = 머리 「표 값」 * 표 값(804-3) = [표 · 칸] 단추 → 표 고르기 창(`line_stack_pick.ts`) · 값 칸 누름 = 그 줄 · 칸(`pickCell`) * ========================================================================== */ import { createButton, createIconButton } from "@ui/ui_template_button"; import { eqText } from "@ui/ui_template_equation"; import { el, showToast } from "@ui/ui_template_elements"; import { createInputField } from "@ui/ui_template_input"; import { DocError, rename, splitId, textOf, varId, type FormulaDoc, } from "../common_util/common_util_formula_doc"; import { isNum } from "../common_util/common_util_formula_doc_calc"; import { decide, hasIf, tryParse } from "../common_util/common_util_formula_doc_view"; import { dropSyms, freshId, newVar, setRow, shapeOf, sourceOf, unfold, type StackItem, type StackLine, } from "../common_util/common_util_line_stack"; import { aliasText, boundConds, currentRow, pickCell, shownUnit, tableAlias, } from "../common_util/common_util_line_stack_pick"; import { openFindPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Find_Pick"; import type { EditHooks } from "./line_stack_edit"; import { openTablePick } from "./line_stack_pick"; import { lt } from "./line_stack_text"; import { whyText } from "./line_stack_why"; import { createText } from "@ui/ui_template_text"; const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; type Block = Extract; const LINK_TAG: Record = { 표: "표", 요소: "요소", 단가산출: "단가산출", 선택: "선택", 입력: "입력", }; /** 출처 꼬리표 — 긴 글은 칸 안에서 줄임표 · 마우스 글에 전체(요구 692-3) */ const clipTag = (text: string): HTMLElement => el("span", { className: "lstk-tag", text, attrs: { title: text } }); type Link = NonNullable[number]["연결"]>; /** 표 값 — [표 · 칸] 단추 → 표 고르기 창(804-3) */ function tableBtn(h: EditHooks, id: string, link: Link): HTMLElement { const vals = (h.answer.값 ?? {}) as Record; const b = createButton({ label: [ lt("Tbl"), ...Object.values(link.조건 ?? {}).flatMap((t) => /^'.*'$/.test(String(t)) ? [String(t).slice(1, -1)] : [], ), link.칸, ] .filter(Boolean) .join(" · ") + " ▾", // 고른 줄(글 조건) · 칸 variant: "ghost", size: 24, clip: true, // 긴 줄 이름 = 칸 안에서 줄임(요구 692-3) onClick: () => void h.table(String(link.표 ?? "")).then((t) => { if (!t) return void showToast(lt("Tbl_Missing"), "warning"); openTablePick({ table: t, now: currentRow(t, link, vals), col: String(link.칸 ?? ""), bound: boundConds(h.doc, link), done: (row, col) => h.change((doc) => pickCell(doc, id, t, row, col)), }); }), }); b.dataset.tablePick = id; b.title = `${b.title} — ${h.name(String(link.표 ?? "")) || String(link.표 ?? "")}`; return b; } /** 값 기호의 출처 꼬리표(식 없는 기호) — 표 = 표 고르기 단추 · 표 값 직접 침 = 「직접 입력」 · 그 밖 연결 = 글 · * 공식 묶음 값 줄 · 금액 줄 수량 칸(요구 701-1) 같은 꼴 */ export function sourceTag(h: EditHooks, id: string, link: Link | undefined): HTMLElement | null { if (!link) return null; if (link.갈래 === "표") return tableBtn(h, id, link); if (link.갈래 === "숫자" && link.표) return el("span", { className: "lstk-tag", text: lt("Direct"), attrs: { title: lt("Direct_Hint") }, }); if (!LINK_TAG[link.갈래 ?? ""]) return null; const sub = link.갈래 === "단가산출" && link.키 ? h.subDoc(link.키) : null; return clipTag( `${LINK_TAG[link.갈래!]}${link.키 && !link.키.includes("{") ? ` · ${sub?.이름 ?? h.name(link.키) ?? ""}` : ""}`, ); } 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, hide: (id: string) => boolean = () => false, ): HTMLElement[] { const d = h.doc; const at = `${i}.${j}`; const editing = (id: string): boolean => h.open.has(`${at}:${id}`); /** 그 줄 고침 켬 · 끔(화면 상태) */ const flip = (id: string, on: boolean): void => { if (on) h.open.add(`${at}:${id}`); else h.open.delete(`${at}:${id}`); }; 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; // 표 값 기호 = 화면 이름(요구 683 · 문서 기호 그대로) — 칸 글은 화면 이름 ↔ 기호로 오감 const alias = tableAlias(d); const show = (id: string): string => alias[id] ?? id; const shownText = (t: string): string => aliasText(t, alias); const realText = (t: string): string => aliasText(t, alias, true); const valText = (id: string): string => { const v = vals[id]; if (isNum(v) || typeof v === "string") return `${eqText(v)} ${shownUnit(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:") ? lt("My_Tpl") : (h.subDoc(k)?.이름 ?? (h.name(k) || k)); // my: = 옛 내 틀(걷음 · 요구 685) const changed = !!line.틀 && !!line.틀꼴 && shapeOf(d, line.기호) !== line.틀꼴; const tableOnly = line.기호.every((id) => !!vars.get(id)?.연결?.표 && !lines.has(id)); // 직접 친 표 값도 const label = !line.틀 ? lt(tableOnly && line.기호.length ? "K_표값" : "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 }), createIconButton({ icon: "plus", title: lt("Add_Row"), size: 24, onClick: () => h.change((doc, items) => { const id = freshId(doc, "x"); (doc.변수 ??= []).push(newVar(id, { 갈래: "숫자", 값: 0 })); blockOf(items).기호.push(id); flip(id, true); }), }), ], }); 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 tap = (node: HTMLElement, id: string): HTMLElement => { node.classList.add("lstk-tap"); node.tabIndex = 0; node.setAttribute("role", "button"); node.title = lt("Edit_Hint"); const go = (): void => { flip(id, true); h.change(() => undefined); }; node.addEventListener("click", go); node.addEventListener("keydown", (e) => e.key === "Enter" && go()); return node; }; /** 고친 뒤 닫음 */ const done = (id: string, fn: () => void): void => { flip(id, false); fn(); }; const rows = line.기호 .filter((id) => !hide(id)) .map((id) => { const v = vars.get(id); const link = v?.연결; const raw = lines.get(id); // 만약 식 = 결정된 식(지금 옵션 · 중간값으로 탄 갈래만 · 요구 695) — 고치면 그 글로 굳음(만약 사라짐 · 696) const tree = raw !== undefined ? tryParse(raw) : null; const iffy = !!tree && hasIf(tree); const expr = iffy ? textOf(decide(tree!, vals)) : raw; const was = h.before?.get(id); const freed = !iffy && raw !== undefined && !!was && hasIf(tryParse(was) ?? ["num", 0, "0"]); const sub = link?.갈래 === "단가산출" && link.키 ? h.subDoc(link.키) : null; const src = v ? sourceOf(h.doc, v) : null; // 가져온 값(812-3) = 옵션 가로 칸과 같은 값 · 꼬리표만 const srcTag = src === "원파일" ? lt("Src_Native") : src === "엑셀" ? lt("Src_Excel") : src; const tag = expr ? null : srcTag ? clipTag(srcTag) : sourceTag(h, id, link); 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( createButton({ label: lt("Find_Title"), variant: "ghost", size: 24, onClick: () => openFindPicker({ layer: "system", owner: null, title: lt("K_단가산출"), kinds: ["logic"], results: true, logic: (x) => h.change((doc) => { const y = doc.변수?.find((z) => varId(z) === id); if (y) y.연결 = { 갈래: "단가산출", 키: x.키, 입력: {} }; }), }), }), ); const name = tap(el("b", { className: "lstk-sym", text: show(id) }), id); name.title = `${show(id)} — ${name.title}`; // 줄임표로 잘려도 전체 이름 const valueOf = expr ?? (link?.갈래 === "숫자" ? String(link.값 ?? "") : isNum(vals[id]) ? String(vals[id]) : ""); let body: (HTMLElement | string)[]; const open = editing(id); if (open) body = [ input(show(id), lt("Row_Name"), "lstk-f-s", (t) => done(id, () => renameRow(id, t.trim() === show(id) ? id : t)), ), "=", input(shownText(valueOf), lt("Row_Expr"), "lstk-f-l", (t) => done(id, () => h.change((doc) => setRow(doc, id, realText(t)))), ), input(v?.단위 ?? "", lt("Unit"), "lstk-f-s", (t) => done(id, () => 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: () => done(id, () => h.change((doc) => dropSyms(doc, [id]))), }), createIconButton({ icon: "check", title: lt("Close"), size: 24, onClick: () => done(id, () => h.change(() => undefined)), }), ]; else if (expr !== undefined || sub || link?.갈래 === "단가산출") body = [ name, "=", ...(expr ? [tap(el("span", { className: "lstk-expr", text: shownText(expr) }), id)] : []), // 결정된 식이 수 하나면(만약이 수를 탄 줄) 값을 또 적지 않음 ...(expr && iffy && tryParse(expr)?.[0] === "num" ? [] : [...(expr ? ["="] : []), el("span", { className: "lstk-val", text: valText(id) })]), ]; else body = [ name, "=", input(shownText(valueOf), valText(id), "lstk-f-m", (t) => h.change((doc) => setRow(doc, id, realText(t))), ), ...(shownUnit(v?.단위) ? [el("span", { className: "lstk-unit", text: shownUnit(v?.단위) })] : []), ]; const why = iffy ? whyText(tree!, vals, { label: (x) => vars.get(x)?.이름 || splitId(x)[1] || x, shown: shownText, fmt: (x) => eqText(x), }) : ""; const notes = [ ...(why ? [createText(why)] : []), ...(freed ? [createText(lt("If_Freed"), "warning")] : []), ]; notes.forEach((n) => n.classList.add("lstk-wide", "lstk-why")); const row = el("div", { className: `lstk-frow ${open ? "is-open" : ""}`, children: [ ...body, ...(tag ? [tag] : []), ...(tools.length ? [el("span", { className: "lstk-rtools", children: tools })] : []), ...notes, ], }); row.dataset.sym = id; return row; }); return [head, ...rows]; }