- 표 부품 combo 선택(구조물 조합 표만): 단위 줄 · 옛 안내글 없음 · 열 폭 = 머리 이름 · 일위대가 키 · 도번 등 번호가 한 줄에 들게(저장 폭 안 씀 · 번호 줄 줄바꿈 없음) · 고른 열의 가장 깊은 이름 칸 강조(나무에서 고를 때도) · 방향키가 숨은 단위 줄을 안 밟음 - 머리 이름 칸 두 번 누르기 · F2 · Enter → headRenameEdit(가장 깊은 칸 = 항목 · 위 칸 = 폴더) → 나무 editFromTable(메뉴 이름 바꾸기와 같은 길 — 저장 물음 · 같은 이름 막음 안내 · 다시 그림) · 쓸 수 있는 층만 - 시험 test_m02_combo_table 3 · test_m02_combo_screen 머리 잠금 기대값 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
306 lines
12 KiB
TypeScript
306 lines
12 KiB
TypeScript
/* =============================================================================
|
|
* ui_template_sheet_render.ts
|
|
* 표 그리기 — 문서 + 풀이 결과 → 쪽마다 `<table>`(머리 층 · 단위 줄 · 본문 · 합계 줄).
|
|
*
|
|
* 칸마다 `data-r`(줄 열쇠) · `data-c`(열 id) — 고치기 · 키보드는 격자(`ui_template_sheet.ts`)가 위임으로 받음.
|
|
* 줄 열쇠 — 본문 `d:<줄id>` · 합계 `t:<합계id>` · 마스터 줄 `s:unit|price|drawing|area|material`.
|
|
* master = 본문 위에 열마다 일위대가 · 도면 · 면적산출 · 재료산출 줄 · project = 쪽줄마다 쪽을 나눔(쪽마다 머리 · 합계는 마지막 쪽).
|
|
* 머리 층 줄 머리 칸은 `data-layer`(층 차례) — master 에서 층 이름을 고침.
|
|
* ========================================================================== */
|
|
|
|
import { el } from "@ui/ui_template_elements";
|
|
import { headDepth, layoutHead } from "./ui_template_sheet_header";
|
|
import { columnNames, formulaLabel, headMinWidths, textWidth } from "./ui_template_sheet_labels";
|
|
import {
|
|
cellEditable,
|
|
isBoundColumn,
|
|
isBlankPick,
|
|
isCalcColumn,
|
|
KEY_AREA,
|
|
KEY_MATERIAL,
|
|
KEY_UNIT,
|
|
MASTER_ROWS,
|
|
type MasterRowKey,
|
|
PICK_ROWS,
|
|
pagePlan,
|
|
REF_ROWS,
|
|
type SheetMode,
|
|
} from "./ui_template_sheet_ops";
|
|
import { groupDigits, isNotice, totalFormula } from "./ui_template_sheet_recalc";
|
|
import { st } from "./ui_template_sheet_text";
|
|
import type { SheetColumn, SheetDoc, SheetResult, SheetRow } from "./ui_template_sheet_types";
|
|
|
|
const GUTTER_MIN = 64;
|
|
const GUTTER_MAX = 140;
|
|
const DEFAULT_WIDTH = { 수: 72, 글: 96 };
|
|
|
|
export interface RenderState {
|
|
doc: SheetDoc;
|
|
mode: SheetMode;
|
|
result: SheetResult;
|
|
sel: { r: string; c: string } | null;
|
|
/** 도면 · 면적산출 · 재료산출 줄 칸 글(열 id) — 없거나 빈 글이면 「없음」. */
|
|
drawingLabel?: (colId: string) => string;
|
|
areaLabel?: (colId: string) => string;
|
|
materialLabel?: (colId: string) => string;
|
|
/** 조회 후 선택 줄(`PICK_ROWS`)을 고름으로 받나 — 칸 겉모양만. */
|
|
pick?: boolean;
|
|
/** 머리 · 층 이름을 못 고침(`lockHead`) — 칸 겉모양만. */
|
|
headLocked?: boolean;
|
|
/** 구조물 조합 표(`combo`) — 단위 줄 없음 · 열 폭 = 머리 · 번호 글이 한 줄에 들게(저장 폭 안 씀) ·
|
|
* `headEditable` = 머리 이름 칸을 고칠 수 있음(나무 이름 바꾸기로). */
|
|
combo?: boolean;
|
|
headEditable?: boolean;
|
|
}
|
|
|
|
/** 도면 · 면적산출 · 재료산출 줄 칸 글. */
|
|
export const refText = (state: RenderState, key: string, colId: string): string =>
|
|
(key === KEY_AREA
|
|
? state.areaLabel
|
|
: key === KEY_MATERIAL
|
|
? state.materialLabel
|
|
: state.drawingLabel)?.(colId)?.trim() || st("Drawing_None");
|
|
|
|
/** 열 폭 — 저장 폭(없으면 기본) · 머리 글이 안 잘리는 최소 폭보다 좁지 않게. */
|
|
const columnWidth = (doc: SheetDoc, col: SheetColumn, mins: Map<string, number>): number =>
|
|
Math.max(
|
|
doc.보기?.열너비?.[col.id] ?? DEFAULT_WIDTH[col.꼴 === "글" ? "글" : "수"],
|
|
mins.get(col.id) ?? 0,
|
|
);
|
|
|
|
/** 구조물 조합 — 일위대가 · 도면 · 면적산출 · 재료산출 줄 글(키 · 번호)이 줄바꿈 없이 들게 최소 폭을 넓힘
|
|
* (`.ui-sheet__meta td` 글꼴과 같게 잼). */
|
|
function fitMeta(state: RenderState, cols: SheetColumn[], mins: Map<string, number>): void {
|
|
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
|
const font = `400 ${rem * 0.76}px ${getComputedStyle(document.body).fontFamily}`;
|
|
for (const col of cols) {
|
|
const texts = [col.일위대가 ?? "", ...REF_ROWS.map((key) => refText(state, key, col.id))];
|
|
const need = Math.ceil(Math.max(...texts.map((t) => textWidth(t, font)))) + 14;
|
|
mins.set(col.id, Math.max(mins.get(col.id) ?? 0, need));
|
|
}
|
|
}
|
|
|
|
/** 머리 칸 글꼴 — 폭 재기용(`.ui-sheet__table thead` 와 같게). */
|
|
function headFont(): string {
|
|
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
|
return `600 ${rem * 0.82}px ${getComputedStyle(document.body).fontFamily}`;
|
|
}
|
|
|
|
/** 칸을 고칠 수 있나 — 줄 열쇠 기준(머리 칸은 따로) · 번호 글 줄은 손으로 못 침. */
|
|
export function keyEditable(state: RenderState, key: string, col: SheetColumn): boolean {
|
|
if (REF_ROWS.includes(key) || isBlankPick(key, col)) return false;
|
|
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 kindClass(col: SheetColumn): string | null {
|
|
if (isCalcColumn(col)) return "is-calc";
|
|
if (isBoundColumn(col)) return "is-bound";
|
|
return col.손 ? "is-hand" : 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 names = columnNames(cols);
|
|
const font = headFont();
|
|
const mins = headMinWidths(cols, depth, font);
|
|
if (state.combo) fitMeta(state, cols, mins);
|
|
const widthDoc = state.combo ? { ...doc, 보기: { ...doc.보기, 열너비: {} } } : doc;
|
|
// 줄 머리 칸 폭 — 층 이름 · 단위 · 일위대가 · 도면 · 면적산출 · 재료산출 · 합계 이름이 안 잘리게
|
|
const GUTTER = Math.min(
|
|
GUTTER_MAX,
|
|
Math.max(
|
|
GUTTER_MIN,
|
|
...[
|
|
...(doc.층 ?? []),
|
|
st("Row_Unit"),
|
|
st("Row_Unit_Price"),
|
|
st("Row_Drawing"),
|
|
st("Row_Area"),
|
|
st("Row_Material"),
|
|
...(doc.합계줄 ?? []).map((t) => t.이름),
|
|
].map((label) => Math.ceil(textWidth(label, font)) + 16),
|
|
),
|
|
);
|
|
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(widthDoc, col, mins);
|
|
}
|
|
|
|
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 = kindClass(col);
|
|
if (kind) td.classList.add(kind);
|
|
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");
|
|
if (!isNotice(why)) 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: MasterRowKey): HTMLElement => {
|
|
const label = (
|
|
{
|
|
"s:price": "Row_Unit_Price",
|
|
"s:drawing": "Row_Drawing",
|
|
"s:area": "Row_Area",
|
|
"s:material": "Row_Material",
|
|
} as const
|
|
)[key];
|
|
const tr = el("tr", { className: "ui-sheet__meta", children: [gutter("th", st(label))] });
|
|
cols.forEach((col, j) => {
|
|
const blank = isBlankPick(key, col);
|
|
const text = blank
|
|
? ""
|
|
: REF_ROWS.includes(key)
|
|
? refText(state, key, col.id)
|
|
: (col.일위대가 ?? "");
|
|
const td = bodyCell(key, col, j, text);
|
|
td.classList.remove("is-num");
|
|
if (state.pick && PICK_ROWS.includes(key) && !blank) td.classList.add("is-pick");
|
|
tr.append(td);
|
|
});
|
|
return tr;
|
|
};
|
|
|
|
const thead = (): HTMLElement => {
|
|
const rows = head.map((cells, level) => {
|
|
const heads = mode === "master" && (!state.headLocked || !!state.headEditable);
|
|
const layer = gutter("th", doc.층?.[level] ?? "", heads ? "is-editable" : "");
|
|
layer.dataset.layer = String(level);
|
|
const tr = el("tr", { children: [layer] });
|
|
for (const cell of cells) {
|
|
const th = el("th", {
|
|
text: cell.label,
|
|
attrs: {
|
|
title: cell.label,
|
|
colspan: String(cell.colspan),
|
|
rowspan: String(cell.rowspan),
|
|
"data-level": String(cell.level),
|
|
"data-col": cols[cell.col].id,
|
|
},
|
|
});
|
|
if (heads) 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: state.combo ? rows : [...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]));
|
|
const formula = totalFormula(total, col);
|
|
if (formula)
|
|
td.title = td.classList.contains("is-error")
|
|
? `${td.title}
|
|
${formulaLabel(formula, names)}`
|
|
: formulaLabel(formula, names);
|
|
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(widthDoc, col, mins)}px`, "data-col": col.id },
|
|
}),
|
|
),
|
|
],
|
|
});
|
|
|
|
const plan = pagePlan(doc, mode);
|
|
const pageCount = plan.length;
|
|
const pages: HTMLElement[] = [];
|
|
for (const [p, page] of plan.entries()) {
|
|
const body = el("tbody", {
|
|
children: [
|
|
...(mode === "master" && p === 0 ? MASTER_ROWS.map(masterRow) : []),
|
|
...page.rows.map((row, i) => dataRow(row, page.first + i)),
|
|
...(page.totals ? 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 });
|
|
}
|