From 184d79cbdfcacfcd4fb74f0e79fa69ab0c567cac Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 23:06:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui=5Ftemplate):=20B08=20=C2=B7=20B09=20?= =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20=EB=A7=8E=EC=9D=80=20=EC=9E=90=EB=A3=8C=20?= =?UTF-8?q?=ED=91=9C=20=EC=96=91=EC=8B=9D(70-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createDataTable — 묶음 머리 여러 줄 · 머리 · 앞 열 · 합계 줄 고정 스크롤 - 숫자 오른쪽 맞춤 · 고정폭 숫자 · 천 단위 쉼표 · 열마다 소수 자리 · 빈 값 「—」 - 줄 번갈아 색 · 올린 줄 강조 · 테마 토큰만 · 보이는 줄 근처만 그림(1,000줄 64~118ms) - 견본(머리 3줄 · 30열 · 66줄 · 합계) 시험 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- resources/tester/fixture_data_table_sample.js | 58 ++++++ resources/tester/helper_ui_data_table.cjs | 79 +++++++ resources/tester/test_ui_data_table.py | 87 ++++++++ ui_template/ui_template_data_table.css | 129 ++++++++++++ ui_template/ui_template_data_table.ts | 195 ++++++++++++++++++ 5 files changed, 548 insertions(+) create mode 100644 resources/tester/fixture_data_table_sample.js create mode 100644 resources/tester/helper_ui_data_table.cjs create mode 100644 resources/tester/test_ui_data_table.py create mode 100644 ui_template/ui_template_data_table.css create mode 100644 ui_template/ui_template_data_table.ts 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); + }, + }; +}