/* ============================================================================= * ui_template_sheet_render.ts * 표 그리기 — 문서 + 풀이 결과 → 쪽마다 ``(머리 층 · 단위 줄 · 본문 · 합계 줄). * * 칸마다 `data-r`(줄 열쇠) · `data-c`(열 id) — 고치기 · 키보드는 격자(`ui_template_sheet.ts`)가 위임으로 받음. * 줄 열쇠 — 본문 `d:<줄id>` · 합계 `t:<합계id>` · 마스터 줄 `s:unit|formula|desc|price`. * master = 값 대신 열마다 식 · 들어갈 것 · 일위대가 줄 · project = 쪽줄마다 쪽을 나눔(쪽마다 머리 · 합계는 마지막 쪽). * ========================================================================== */ import { el } from "@ui/ui_template_elements"; import { headDepth, layoutHead } from "./ui_template_sheet_header"; import { cellEditable, isBoundColumn, isCalcColumn, orderedRows, type SheetMode, } from "./ui_template_sheet_ops"; import { groupDigits, totalFormula } from "./ui_template_sheet_recalc"; import { st } from "./ui_template_sheet_text"; import type { SheetColumn, SheetDoc, SheetResult, SheetRow } from "./ui_template_sheet_types"; export const KEY_UNIT = "s:unit"; const MASTER_ROWS = ["s:formula", "s:desc", "s:price"] as const; const GUTTER = 48; const DEFAULT_WIDTH = { 수: 72, 글: 96 }; const PAGE_ROWS = 50; export interface RenderState { doc: SheetDoc; mode: SheetMode; result: SheetResult; sel: { r: string; c: string } | null; } export const columnWidth = (doc: SheetDoc, col: SheetColumn): number => doc.보기?.열너비?.[col.id] ?? DEFAULT_WIDTH[col.꼴 === "글" ? "글" : "수"]; /** 키보드로 옮겨 다니는 줄 차례(쪽을 가로지름). */ export function navRows(doc: SheetDoc, mode: SheetMode): string[] { const special = mode === "master" ? [KEY_UNIT, ...MASTER_ROWS] : [KEY_UNIT]; return [ ...special, ...orderedRows(doc).map((r) => `d:${r.id}`), ...(doc.합계줄 ?? []).map((t) => `t:${t.id}`), ]; } /** 칸을 고칠 수 있나 — 줄 열쇠 기준(머리 칸은 따로). */ export function keyEditable(state: RenderState, key: string, col: SheetColumn): boolean { if (key.startsWith("s:")) return state.mode === "master"; if (key.startsWith("t:")) return false; const row = state.doc.줄.find((r) => `d:${r.id}` === key); return !!row && cellEditable(state.mode, col, row); } const shown = (text: string | undefined): string => !text || text === "0" ? "" : groupDigits(text); function kindOf(col: SheetColumn): { cls: string; label: string } | null { if (isBoundColumn(col)) return { cls: "is-bound", label: st("Kind_Bound") }; if (isCalcColumn(col)) return { cls: "is-calc", label: st("Kind_Calc") }; if (col.손) return { cls: "is-hand", label: st("Kind_Hand") }; return null; } export function renderSheet(state: RenderState): HTMLElement { const { doc, mode } = state; const cols = doc.열; const depth = headDepth(cols, doc.층); const head = layoutHead(cols, depth); const errors = new Map(state.result.오류.map((e) => [`d:${e.줄}|${e.열}`, e.까닭])); for (const e of state.result.오류) errors.set(`t:${e.줄}|${e.열}`, e.까닭); const frozen = Math.min(doc.보기?.틀고정?.열 ?? 0, cols.length); const lefts: number[] = []; let left = GUTTER; for (const col of cols) { lefts.push(left); left += columnWidth(doc, col); } const freeze = (cell: HTMLElement, from: number, span = 1): void => { if (from + span > frozen) return; cell.classList.add("is-frozen"); cell.style.left = `${lefts[from]}px`; }; const gutter = (tag: "th" | "td", text: string, cls = ""): HTMLElement => el(tag, { className: `ui-sheet__gutter ${cls}`.trim(), text }); const bodyCell = (key: string, col: SheetColumn, j: number, text: string): HTMLElement => { const td = el("td", { text, attrs: { "data-r": key, "data-c": col.id } }); const kind = kindOf(col); if (kind) td.classList.add(kind.cls); if (col.꼴 !== "글") td.classList.add("is-num"); td.classList.add(keyEditable(state, key, col) ? "is-editable" : "is-locked"); const why = errors.get(`${key}|${col.id}`); if (why) { td.classList.add("is-error"); td.textContent = st("Error"); td.title = why; } else if (text) td.title = text; if (state.sel?.r === key && state.sel.c === col.id) td.classList.add("is-selected"); freeze(td, j); return td; }; const dataRow = (row: SheetRow, n: number): HTMLElement => { const key = `d:${row.id}`; const tr = el("tr", { children: [gutter("th", String(n))] }); if (row.고정) tr.classList.add("is-fixed"); cols.forEach((col, j) => { const formula = row.식?.[col.id] ?? col.식; const raw = row.값[col.id]; const text = formula ? shown(state.result.계산[row.id]?.[col.id]) : raw === null || raw === undefined ? "" : col.꼴 !== "글" && typeof raw === "number" ? groupDigits(String(raw)) : String(raw); const td = bodyCell(key, col, j, text); if (row.식?.[col.id]) td.classList.add("is-override"); tr.append(td); }); return tr; }; const masterRow = (key: (typeof MASTER_ROWS)[number]): HTMLElement => { const label = { "s:formula": "Row_Formula", "s:desc": "Row_Desc", "s:price": "Row_Unit_Price" }[ key ] as "Row_Formula" | "Row_Desc" | "Row_Unit_Price"; const tr = el("tr", { className: "ui-sheet__meta", children: [gutter("th", st(label))] }); cols.forEach((col, j) => { const text = key === "s:formula" ? (col.식 ?? "") : key === "s:desc" ? (col.설명 ?? "") : (col.일위대가 ?? ""); const td = bodyCell(key, col, j, text); td.classList.remove("is-num"); const kind = kindOf(col); if (key === "s:desc" && kind) { const chip = el("span", { className: `ui-sheet__chip ${kind.cls}`, text: col.펼침 ? `${kind.label} · ${st("Kind_Spread")}` : kind.label, }); td.prepend(chip); } tr.append(td); }); return tr; }; const thead = (): HTMLElement => { const rows = head.map((cells, level) => { const tr = el("tr", { children: [gutter("th", doc.층?.[level] ?? "")] }); for (const cell of cells) { const th = el("th", { text: cell.label, attrs: { colspan: String(cell.colspan), rowspan: String(cell.rowspan), "data-level": String(cell.level), "data-col": cols[cell.col].id, }, }); if (mode === "master") th.classList.add("is-editable"); freeze(th, cell.col, cell.colspan); tr.append(th); } return tr; }); const unit = el("tr", { className: "ui-sheet__unit", children: [gutter("th", st("Row_Unit"))], }); cols.forEach((col, j) => { const th = bodyCell(KEY_UNIT, col, j, col.단위 ?? ""); th.classList.remove("is-num"); th.append(el("span", { className: "ui-sheet__resize", attrs: { "data-resize": col.id } })); unit.append(th); }); return el("thead", { children: [...rows, unit] }); }; const totalRows = (): HTMLElement[] => (doc.합계줄 ?? []).map((total) => { const tr = el("tr", { className: "ui-sheet__total", children: [gutter("th", total.이름)] }); cols.forEach((col, j) => { const td = bodyCell(`t:${total.id}`, col, j, shown(state.result.합계[total.id]?.[col.id])); if (totalFormula(total, col)) td.title = totalFormula(total, col)!; tr.append(td); }); return tr; }); const colgroup = el("colgroup", { children: [ el("col", { attrs: { style: `width:${GUTTER}px` } }), ...cols.map((col) => el("col", { attrs: { style: `width:${columnWidth(doc, col)}px`, "data-col": col.id } }), ), ], }); const data = orderedRows(doc); const size = mode === "project" ? Math.max(1, doc.쪽줄 ?? PAGE_ROWS) : Math.max(1, data.length); const pageCount = Math.max(1, Math.ceil(data.length / size)); const pages: HTMLElement[] = []; for (let p = 0; p < pageCount; p += 1) { const slice = data.slice(p * size, (p + 1) * size); const body = el("tbody", { children: [ ...(mode === "master" && p === 0 ? MASTER_ROWS.map(masterRow) : []), ...slice.map((row, i) => dataRow(row, p * size + i + 1)), ...(p === pageCount - 1 ? totalRows() : []), ], }); const table = el("table", { className: "ui-sheet__table", attrs: { style: `width:${left}px` }, children: [colgroup.cloneNode(true) as HTMLElement, thead(), body], }); if (pageCount > 1) { table.prepend( el("caption", { text: `${p + 1} / ${pageCount} ${st("Page")}`, className: "ui-sheet__page", }), ); } pages.push(table); } return el("div", { className: "ui-sheet__pages", children: pages }); }