feat(B08): 구조물 집계표를 공용 많은 자료 표로(70-2)

- 집계표 = createDataTable — 머리 두 줄(조합 구분 · 이름) · 측점 열 고정 · 칸은 빈칸 · 빠진 조합 빨강 · 풍선 글 그대로
- 공용 표 하위 호환 옵션 — empty(빈 값 글) · 열 title · className · 빠진 항목 머리 색 · 틀 폭은 표만큼

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
This commit is contained in:
2026-10-06 23:15:42 +09:00
co-authored by Claude Opus 5.5
parent 184d79cbdf
commit 53d839cb1d
6 changed files with 82 additions and 55 deletions
+27 -11
View File
@@ -24,6 +24,10 @@ export interface DataColumn {
decimals?: number;
/** 열 폭 px(기본 숫자 84 · 글 96) */
width?: number;
/** 맨 아래 머리 칸 풍선 글(기본 label) */
title?: string;
/** 맨 아래 머리 칸에 더할 class(예 빠진 항목 빨강) */
className?: string;
}
export type DataRow = Record<string, unknown>;
@@ -37,6 +41,8 @@ export interface DataTableOptions {
total?: DataRow | null;
/** 표 이름(화면 읽기용) */
caption?: string;
/** 빈 값 글(기본 「—」 · 사용자가 채울 칸은 "") */
empty?: string;
}
export interface DataTable {
@@ -53,10 +59,14 @@ function colWidth(c: DataColumn): number {
}
/** 값 한 칸 → [보일 글, 빈 값인지] */
export function formatCell(v: unknown, fmt: Intl.NumberFormat | null): [string, boolean] {
if (v === null || v === undefined || v === "") return [EMPTY, true];
export function formatCell(
v: unknown,
fmt: Intl.NumberFormat | null,
empty = EMPTY,
): [string, boolean] {
if (v === null || v === undefined || v === "") return [empty, true];
if (fmt && typeof v === "number")
return Number.isFinite(v) ? [fmt.format(v), false] : [EMPTY, true];
return Number.isFinite(v) ? [fmt.format(v), false] : [empty, true];
return [String(v), false];
}
@@ -65,10 +75,13 @@ function headHtml(cols: DataColumn[], left: (number | null)[]): string {
const same = (a: DataColumn, b: DataColumn, n: number) =>
(b.group?.length ?? 0) > n &&
a.group!.slice(0, n + 1).join("\u0000") === b.group!.slice(0, n + 1).join("\u0000");
const stick = (from: number, to: number) =>
left[to] !== null
? ` class="is-frozen${left[to + 1] == null ? " is-edge" : ""}" style="left:${left[from]}px"`
: "";
const stick = (from: number, to: number, extra = "") => {
const frz = left[to] !== null;
const cls = [frz && "is-frozen", frz && left[to + 1] == null && "is-edge", extra]
.filter(Boolean)
.join(" ");
return (cls ? ` class="${esc(cls)}"` : "") + (frz ? ` style="left:${left[from]}px"` : "");
};
const rows: string[] = [];
for (let lv = 0; lv <= depth; lv++) {
let tr = "";
@@ -83,7 +96,8 @@ function headHtml(cols: DataColumn[], left: (number | null)[]): string {
} else if (g === lv) {
const num = c.kind === "number" ? ' data-num=""' : "";
const span = depth - lv + 1;
tr += `<th scope="col" rowspan="${span}"${num}${stick(j, j)} title="${esc(c.label)}">${esc(c.label)}</th>`;
const tip = esc(c.title ?? c.label);
tr += `<th scope="col" rowspan="${span}"${num}${stick(j, j, c.className)} title="${tip}">${esc(c.label)}</th>`;
}
}
rows.push(`<tr>${tr}</tr>`);
@@ -97,10 +111,11 @@ function rowHtml(
left: (number | null)[],
r: DataRow,
alt = false,
blank = EMPTY,
): string {
let tr = "";
for (let j = 0; j < cols.length; j++) {
const [txt, empty] = formatCell(r[cols[j].key], fmts[j]);
const [txt, empty] = formatCell(r[cols[j].key], fmts[j], blank);
const frz = left[j] !== null;
const cls = [frz && "is-frozen", frz && left[j + 1] == null && "is-edge", empty && "is-empty"]
.filter(Boolean)
@@ -147,9 +162,10 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin
const from = win ? Math.max(0, Math.min(win.from, n)) : 0;
const to = win ? Math.max(from, Math.min(win.to, n)) : n;
let body = win ? pad(from * win.rowH, cols.length) : "";
for (let i = from; i < to; i++) body += rowHtml(cols, fmts, left, o.rows[i], i % 2 === 1);
const blank = o.empty ?? EMPTY;
for (let i = from; i < to; i++) body += rowHtml(cols, fmts, left, o.rows[i], i % 2 === 1, blank);
if (win) body += pad((n - to) * win.rowH, cols.length);
const foot = o.total ? `<tfoot>${rowHtml(cols, fmts, left, o.total)}</tfoot>` : "";
const foot = o.total ? `<tfoot>${rowHtml(cols, fmts, left, o.total, false, blank)}</tfoot>` : "";
const cap = o.caption ? `<caption>${esc(o.caption)}</caption>` : "";
return (
`<table class="ui-data-table__table" style="width:${x}px">${cap}<colgroup>${colgroup}</colgroup>` +