diff --git a/resources/tester/fixture_data_table_sample.js b/resources/tester/fixture_data_table_sample.js
new file mode 100644
index 000000000..d4ad61c0d
--- /dev/null
+++ b/resources/tester/fixture_data_table_sample.js
@@ -0,0 +1,58 @@
+/* 공용 표(`ui_template/ui_template_data_table.ts`) 견본 — 토적표 모양 · 머리 3줄 · 30열 · 합계.
+ * 그냥 글(스크립트)로 읽어 씀 — Node 헬퍼와 화면 eval 이 같은 견본을 씀.
+ * const s = sampleDataTable(66); // { columns, rows, total, frozen } */
+function sampleDataTable(n) {
+ const num = (key, label, group, decimals) => ({ key, label, group, kind: "number", decimals });
+ const trio = (pre, group) => [
+ num(pre + "_area", "단면적", group, 2),
+ num(pre + "_vol", "입적", group, 1),
+ num(pre + "_adj", "보정량", group, 1),
+ ];
+ const pair = (pre, group) => [
+ num(pre + "_len", "거리", group, 1),
+ num(pre + "_area", "면적", group, 1),
+ ];
+ const columns = [
+ { key: "sta", label: "측점", width: 80 },
+ num("dist", "거리", [], 0),
+ ...trio("cs", ["절토", "토사"]),
+ ...trio("cr", ["절토", "연암"]),
+ ...trio("ds", ["절토", "측구파기 토사"]),
+ ...trio("dr", ["절토", "측구파기 암"]),
+ num("adj_sum", "보정량계", ["절토"], 1),
+ num("fill_area", "단면적", ["성토"], 2),
+ num("fill_vol", "입적", ["성토"], 1),
+ num("reuse", "유용토", [], 1),
+ num("diff", "차인토량", [], 1),
+ num("cum", "누가토량", [], 1),
+ ...pair("gf", ["면고르기", "성토면"]),
+ ...pair("gc", ["면고르기", "절토면"]),
+ ...pair("tf", ["지장목제거", "성토"]),
+ ...pair("tc", ["지장목제거", "절토"]),
+ ...pair("rd", ["노면정리"]),
+ ];
+ const rows = [];
+ let cum = 0;
+ for (let i = 0; i < n; i++) {
+ const r = {
+ sta: i % 5 ? `${120 + Math.floor(i / 5)}+${(i % 5) * 4}` : String(120 + i / 5),
+ dist: i ? 4 : 0,
+ };
+ columns.slice(2).forEach((c, j) => {
+ // 빈 값 · 큰 값 · 음수가 섞이게
+ r[c.key] =
+ (i + j) % 7 === 0
+ ? null
+ : Math.round(((i * 37 + j * 101) % 997) * (j % 4 === 0 ? 13.7 : 1.13) * 100) / 100;
+ });
+ r.diff = Math.round((r.fill_vol ?? 0) - (r.cs_vol ?? 0)) / 1;
+ cum += r.diff;
+ r.cum = cum;
+ rows.push(r);
+ }
+ const total = { sta: "합계" };
+ columns.slice(1).forEach((c) => {
+ if (c.key !== "cum") total[c.key] = rows.reduce((s, r) => s + (r[c.key] ?? 0), 0);
+ });
+ return { columns, rows, total, frozen: 1 };
+}
diff --git a/resources/tester/helper_ui_data_table.cjs b/resources/tester/helper_ui_data_table.cjs
new file mode 100644
index 000000000..aae5bae00
--- /dev/null
+++ b/resources/tester/helper_ui_data_table.cjs
@@ -0,0 +1,79 @@
+/* 공용 표(`ui_template/ui_template_data_table.ts`) 단위검증 헬퍼 —
+ * 트랜스파일해 Node 로 견본 표 HTML 을 만들고 판정할 값을 JSON 으로 냄. */
+const fs = require("fs");
+const path = require("path");
+const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
+
+const file = path.join(__dirname, "..", "..", "ui_template", "ui_template_data_table.ts");
+const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
+ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
+}).outputText;
+const box = { exports: {} };
+const req = (p) => (p.endsWith(".css") ? {} : require(p));
+new Function("exports", "module", "require", js)(box.exports, box, req);
+const m = box.exports;
+
+const src = fs.readFileSync(path.join(__dirname, "fixture_data_table_sample.js"), "utf8");
+const sampleDataTable = new Function(src + "; return sampleDataTable;")();
+
+const s = sampleDataTable(66);
+const html = m.renderDataTableHtml(s);
+const head = html.split("")[1].split("")[0];
+const headRows = head.split("").filter(Boolean);
+const body = html.split("
")[1].split("")[0];
+const bodyRows = body.split("").filter(Boolean);
+const foot = html.split("")[1].split("")[0];
+
+const t0 = process.hrtime.bigint();
+const big = m.renderDataTableHtml(sampleDataTable(1000));
+const ms = Number(process.hrtime.bigint() - t0) / 1e6;
+
+const one = (c, v) =>
+ m.formatCell(
+ v,
+ c.kind === "number"
+ ? new Intl.NumberFormat("ko-KR", {
+ minimumFractionDigits: c.decimals ?? 0,
+ maximumFractionDigits: c.decimals ?? 0,
+ })
+ : null,
+ )[0];
+const nc = { key: "a", label: "a", kind: "number", decimals: 2 };
+const tc = { key: "b", label: "b" };
+
+console.log(
+ JSON.stringify({
+ cols: s.columns.length,
+ headRows: headRows.length,
+ firstHeadRow: headRows[0],
+ secondHeadRow: headRows[1],
+ bodyRows: bodyRows.length,
+ firstBodyRow: bodyRows[0],
+ foot,
+ frozenCells: (body.match(/class="is-frozen is-edge"/g) || []).length,
+ bigRows: big.split("")[1].split("")[0].split("").length - 1,
+ bigMs: ms,
+ fmt: {
+ thousands: one(nc, 1234567.891),
+ neg: one(nc, -97.9),
+ zero: one(nc, 0),
+ nul: one(nc, null),
+ undef: one(nc, undefined),
+ blank: one(nc, ""),
+ nan: one(nc, NaN),
+ text: one(tc, "120+18"),
+ textNum: one(tc, 5),
+ },
+ win: (() => {
+ const b = m
+ .renderDataTableHtml(sampleDataTable(1000), { from: 61, to: 121, rowH: 25 })
+ .split("")[1]
+ .split("")[0]
+ .split("")
+ .filter(Boolean);
+ return { n: b.length, top: b[0], second: b[1].slice(0, 20), bottom: b[b.length - 1] };
+ })(),
+ escaped: m.renderDataTableHtml({ columns: [{ key: "x", label: "" }], rows: [{ x: "a&<" }] }),
+ noTotal: m.renderDataTableHtml({ columns: [tc], rows: [] }).includes(""),
+ }),
+);
diff --git a/resources/tester/test_ui_data_table.py b/resources/tester/test_ui_data_table.py
new file mode 100644
index 000000000..1b32696f0
--- /dev/null
+++ b/resources/tester/test_ui_data_table.py
@@ -0,0 +1,87 @@
+# -*- coding: utf-8 -*-
+"""공용 표 양식(PLAN 70-1) 회귀검증 — 묶음 머리 · 고정 열 · 숫자 꼴 · 빈 값 · 합계 · 1,000줄.
+
+Node 헬퍼(`helper_ui_data_table.cjs`)가 `ui_template/ui_template_data_table.ts` 를 트랜스파일해
+견본(`fixture_data_table_sample.js` · 머리 3줄 · 30열 · 66줄)으로 HTML 을 만들고, 여기서 판정함.
+"""
+
+import json
+import os
+import subprocess
+
+import pytest
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+
+
+@pytest.fixture(scope="module")
+def r():
+ proc = subprocess.run(
+ ["node", os.path.join(HERE, "helper_ui_data_table.cjs")],
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ timeout=60,
+ )
+ assert proc.returncode == 0, proc.stderr
+ return json.loads(proc.stdout)
+
+
+def test_견본_크기(r):
+ assert r["cols"] == 30
+ assert r["headRows"] == 3
+ assert r["bodyRows"] == 66
+
+
+def test_묶음_머리_합침(r):
+ first = r["firstHeadRow"]
+ # 측점 · 거리 · 유용토 등 묶음 없는 열은 머리 3줄을 세로로 차지
+ assert 'rowspan="3" class="is-frozen is-edge" style="left:0px" title="측점">측점' in first
+ assert first.count('rowspan="3"') == 5 # 측점 거리 유용토 차인토량 누가토량
+ assert 'colspan="13">절토' in first # 토사 · 연암 · 측구파기 토사 · 암 각 3 + 보정량계
+ assert 'colspan="4">면고르기' in first
+ assert 'colspan="2">노면정리' in first
+ second = r["secondHeadRow"]
+ assert 'colspan="3">토사' in second and 'colspan="3">연암' in second
+ assert 'rowspan="2" data-num="" title="보정량계"' in second # 한 단 묶음은 머리 2줄
+
+
+def test_고정_열과_숫자_칸(r):
+ row = r["firstBodyRow"]
+ assert row.startswith('| 120 | ')
+ assert r["frozenCells"] == 66
+ assert '0 | ' in row # 거리 0 은 빈 값 아님
+
+
+def test_숫자_꼴(r):
+ f = r["fmt"]
+ assert f["thousands"] == "1,234,567.89"
+ assert f["neg"] == "-97.90"
+ assert f["zero"] == "0.00"
+ assert f["nul"] == f["undef"] == f["blank"] == f["nan"] == "—"
+ assert f["text"] == "120+18" and f["textNum"] == "5"
+
+
+def test_합계_줄(r):
+ assert r["foot"].startswith('
| 합계 | ')
+ assert "is-empty" in r["foot"] # 누가토량 합계는 비움
+ assert r["noTotal"] is False
+
+
+def test_글_새지_않음(r):
+ assert "<b>" in r["escaped"] and "a&<" in r["escaped"]
+
+
+def test_천_줄_빠름(r):
+ assert r["bigRows"] == 1000
+ assert r["bigMs"] < 300
+
+
+def test_보이는_줄만_그림(r):
+ w = r["win"]
+ assert w["n"] == 62 # 위 빈 줄 + 60줄 + 아래 빈 줄
+ assert 'style="height:1525px">' in w["top"] # 61줄 × 25
+ assert w["second"].startswith(
+ ' |
'
+ ) # 61 번째(홀수) 줄 — 번갈아 색은 전체 줄 번호로
+ assert 'style="height:21975px"' in w["bottom"] # (1000 - 121) × 25
diff --git a/ui_template/ui_template_data_table.css b/ui_template/ui_template_data_table.css
new file mode 100644
index 000000000..cf9b36aec
--- /dev/null
+++ b/ui_template/ui_template_data_table.css
@@ -0,0 +1,129 @@
+/* =============================================================================
+ * ui_template_data_table.css — 많은 자료용 읽기 표(`ui_template_data_table.ts`)
+ * 색은 테마 토큰만 · 칸 배경은 불투명(고정 칸 아래로 비치지 않게)
+ * ========================================================================== */
+
+.ui-data-table {
+ --dt-row-h: 24px;
+ --dt-zebra: var(--color-surface);
+ --dt-hover: color-mix(in srgb, var(--color-accent) 14%, var(--color-canvas));
+ --dt-head: color-mix(in srgb, var(--color-accent) 6%, var(--color-surface));
+ --dt-line: var(--color-border);
+ --dt-edge: color-mix(in srgb, var(--color-text-secondary) 45%, var(--color-border));
+ max-width: 100%;
+ max-height: calc(100vh - 260px);
+ overflow: auto;
+ border: 1px solid var(--dt-line);
+ border-radius: var(--radius-lg);
+ background: var(--color-canvas);
+ outline: none;
+ overflow-anchor: none; /* 줄을 갈아 그릴 때 브라우저가 스크롤을 옮기지 않게 */
+}
+
+.ui-data-table:focus-visible {
+ box-shadow: 0 0 0 2px var(--color-focus-ring);
+}
+
+.ui-data-table__table {
+ table-layout: fixed;
+ border-collapse: separate;
+ border-spacing: 0;
+ color: var(--color-text-body);
+ font-size: var(--text-caption);
+ line-height: 1.3;
+}
+
+.ui-data-table__table caption {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+}
+
+.ui-data-table__table th,
+.ui-data-table__table td {
+ height: var(--dt-row-h);
+ padding: 0 var(--spacing-8);
+ overflow: hidden;
+ border-right: 1px solid var(--dt-line);
+ border-bottom: 1px solid var(--dt-line);
+ background: var(--color-canvas);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* 숫자 — 오른쪽 맞춤 · 자리 폭 같게 */
+.ui-data-table__table [data-num] {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+}
+
+.ui-data-table__table td.is-empty {
+ color: var(--color-text-secondary); /* muted 는 작은 글에 대비 3:1 — 모자람 */
+ text-align: center;
+}
+
+/* 머리 — 스크롤해도 위에 붙음 */
+.ui-data-table__table thead {
+ position: sticky;
+ top: 0;
+ z-index: 2;
+}
+
+.ui-data-table__table thead th {
+ background: var(--dt-head);
+ color: var(--color-text);
+ font-weight: var(--font-weight-medium);
+ text-align: center;
+}
+
+.ui-data-table__table thead tr:last-child th,
+.ui-data-table__table thead th[rowspan] {
+ border-bottom-color: var(--dt-edge);
+}
+
+/* 줄 번갈아 색 · 올린 줄 강조 */
+.ui-data-table__table tbody tr.is-alt td {
+ background: var(--dt-zebra);
+}
+
+.ui-data-table__table tbody tr:hover td {
+ background: var(--dt-hover);
+}
+
+/* 안 그린 줄 자리 */
+.ui-data-table__table tbody tr.ui-data-table__pad td {
+ height: auto;
+ padding: 0;
+ border: 0;
+ background: none;
+}
+
+/* 앞 고정 열 — left 는 칸마다 글에 들어 있음 */
+.ui-data-table__table .is-frozen {
+ position: sticky;
+ z-index: 1;
+}
+
+.ui-data-table__table thead .is-frozen {
+ z-index: 3;
+}
+
+.ui-data-table__table .is-edge {
+ border-right-color: var(--dt-edge);
+}
+
+/* 합계 줄 — 맨 아래 붙음 · 굵게 */
+.ui-data-table__table tfoot {
+ position: sticky;
+ bottom: 0;
+ z-index: 2;
+}
+
+.ui-data-table__table tfoot td {
+ border-top: 1px solid var(--dt-edge);
+ background: var(--dt-head);
+ color: var(--color-text);
+ font-weight: var(--font-weight-bold);
+}
diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts
new file mode 100644
index 000000000..c39f2c1a7
--- /dev/null
+++ b/ui_template/ui_template_data_table.ts
@@ -0,0 +1,195 @@
+/* =============================================================================
+ * ui_template_data_table.ts
+ * 많은 자료용 읽기 표(B08 · B09 공용) — 묶음 머리 여러 줄 · 머리 · 앞 열 고정 스크롤 ·
+ * 숫자 오른쪽 맞춤 · 천 단위 쉼표 · 열마다 소수 자리 · 빈 값 「—」 · 합계 줄.
+ * 줄 1,000개도 빠르게 — HTML 글로 통째로 넣고(칸마다 요소 만들지 않음) 보이는 줄 근처만 그림.
+ *
+ * const t = createDataTable({ columns, rows, frozen: 1, total });
+ * host.append(t.root); t.setRows(rows2, total2);
+ * 열 = { key: "cut_soil_area", label: "단면적", group: ["절토", "토사"], kind: "number", decimals: 2 }
+ * ========================================================================== */
+
+import "./ui_template_data_table.css";
+
+export interface DataColumn {
+ /** 줄 객체에서 값을 꺼낼 키 */
+ key: string;
+ /** 맨 아래 머리 글 */
+ label: string;
+ /** 묶음 머리(위 → 아래) — 이웃 열과 앞부분이 같으면 한 칸으로 합침 */
+ group?: string[];
+ /** 기본 글 · 숫자면 오른쪽 맞춤 · 천 단위 쉼표 */
+ kind?: "number" | "text";
+ /** 숫자 소수 자리(기본 0) */
+ decimals?: number;
+ /** 열 폭 px(기본 숫자 84 · 글 96) */
+ width?: number;
+}
+
+export type DataRow = Record;
+
+export interface DataTableOptions {
+ columns: DataColumn[];
+ rows: DataRow[];
+ /** 앞에서부터 고정할 열 수(기본 0) */
+ frozen?: number;
+ /** 합계 줄(맨 아래 고정) — 없으면 안 그림 */
+ total?: DataRow | null;
+ /** 표 이름(화면 읽기용) */
+ caption?: string;
+}
+
+export interface DataTable {
+ root: HTMLElement;
+ setRows: (rows: DataRow[], total?: DataRow | null) => void;
+}
+
+const EMPTY = "—";
+const ESC: Record = { "&": "&", "<": "<", ">": ">", '"': """ };
+const esc = (s: string): string => s.replace(/[&<>"]/g, (c) => ESC[c]);
+
+function colWidth(c: DataColumn): number {
+ return c.width ?? (c.kind === "number" ? 84 : 96);
+}
+
+/** 값 한 칸 → [보일 글, 빈 값인지] */
+export function formatCell(v: unknown, fmt: Intl.NumberFormat | null): [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 [String(v), false];
+}
+
+function headHtml(cols: DataColumn[], left: (number | null)[]): string {
+ const depth = Math.max(0, ...cols.map((c) => c.group?.length ?? 0));
+ 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 rows: string[] = [];
+ for (let lv = 0; lv <= depth; lv++) {
+ let tr = "";
+ for (let j = 0; j < cols.length; j++) {
+ const c = cols[j];
+ const g = c.group?.length ?? 0;
+ if (g > lv) {
+ let k = j;
+ while (k + 1 < cols.length && same(c, cols[k + 1], lv)) k++;
+ tr += `| ${esc(c.group![lv])} | `;
+ j = k;
+ } else if (g === lv) {
+ const num = c.kind === "number" ? ' data-num=""' : "";
+ const span = depth - lv + 1;
+ tr += `${esc(c.label)} | `;
+ }
+ }
+ rows.push(`${tr}
`);
+ }
+ return rows.join("");
+}
+
+function rowHtml(
+ cols: DataColumn[],
+ fmts: (Intl.NumberFormat | null)[],
+ left: (number | null)[],
+ r: DataRow,
+ alt = false,
+): string {
+ let tr = "";
+ for (let j = 0; j < cols.length; j++) {
+ const [txt, empty] = formatCell(r[cols[j].key], fmts[j]);
+ const frz = left[j] !== null;
+ const cls = [frz && "is-frozen", frz && left[j + 1] == null && "is-edge", empty && "is-empty"]
+ .filter(Boolean)
+ .join(" ");
+ const attrs =
+ (cls ? ` class="${cls}"` : "") +
+ (frz ? ` style="left:${left[j]}px"` : "") +
+ (fmts[j] ? ' data-num=""' : "");
+ tr += `${esc(txt)} | `;
+ }
+ return alt ? `${tr}
` : `${tr}
`;
+}
+
+/** 그릴 줄 구간 — 밖의 줄은 빈 줄 높이(rowH px)로만 채움 */
+export interface RowWindow {
+ from: number;
+ to: number;
+ rowH: number;
+}
+
+const pad = (h: number, n: number) =>
+ h > 0 ? ` |
` : "";
+
+/** 표 전체 HTML 글 — DOM 없이 만듦(시험 · 서버 그리기도 같은 글) · win 없으면 모든 줄 */
+export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): string {
+ const cols = o.columns;
+ const frozen = Math.min(o.frozen ?? 0, cols.length);
+ const left: (number | null)[] = [];
+ let x = 0;
+ cols.forEach((c, i) => {
+ left.push(i < frozen ? x : null);
+ x += colWidth(c);
+ });
+ const fmts = cols.map((c) =>
+ c.kind === "number"
+ ? new Intl.NumberFormat("ko-KR", {
+ minimumFractionDigits: c.decimals ?? 0,
+ maximumFractionDigits: c.decimals ?? 0,
+ })
+ : null,
+ );
+ const colgroup = cols.map((c) => ``).join("");
+ const n = o.rows.length;
+ 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);
+ if (win) body += pad((n - to) * win.rowH, cols.length);
+ const foot = o.total ? `${rowHtml(cols, fmts, left, o.total)}` : "";
+ const cap = o.caption ? `${esc(o.caption)}` : "";
+ return (
+ `${cap}${colgroup}` +
+ `${headHtml(cols, left)}${body}${foot}
`
+ );
+}
+
+/** 화면 위아래로 더 그려 둘 줄 수 · 구간은 이 단위로 끊어 스크롤마다 다시 그리지 않음 */
+const OVER = 40;
+
+export function createDataTable(opts: DataTableOptions): DataTable {
+ const root = document.createElement("div");
+ root.className = "ui-data-table";
+ root.tabIndex = 0; // 키보드로 표 안 스크롤
+ const state = { ...opts };
+ let win: RowWindow = { from: -1, to: -1, rowH: 24 }; // --dt-row-h · 그린 뒤 재서 고침
+ // 보이는 줄 근처만 그림 — 줄 1,000개를 다 배치하면 수백 ms
+ const draw = (force: boolean) => {
+ const n = state.rows.length;
+ const view = Math.ceil((root.clientHeight || 800) / win.rowH);
+ const from = Math.max(0, Math.floor(root.scrollTop / win.rowH / OVER) * OVER - OVER);
+ const to = Math.min(n, from + view + 3 * OVER);
+ if (!force && from === win.from && to === win.to) return;
+ win = { ...win, from, to };
+ root.innerHTML = renderDataTableHtml(state, win);
+ // 줄 높이는 그린 줄을 재서 맞춤(글꼴 · 토큰이 바뀌어도)
+ const tr = root.querySelector("tbody tr:not(.ui-data-table__pad)");
+ if (tr && tr.offsetHeight && tr.offsetHeight !== win.rowH) {
+ win = { ...win, rowH: tr.offsetHeight };
+ draw(true);
+ }
+ };
+ root.addEventListener("scroll", () => draw(false), { passive: true });
+ draw(true);
+ return {
+ root,
+ setRows(rows, total) {
+ state.rows = rows;
+ if (total !== undefined) state.total = total;
+ draw(true);
+ },
+ };
+}