/* ============================================================================= * 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; /** 맨 아래 머리 칸 풍선 글(기본 label) */ title?: string; /** 맨 아래 머리 칸에 더할 class(예 빠진 항목 빨강) */ className?: string; } export type DataRow = Record; export interface DataTableOptions { columns: DataColumn[]; rows: DataRow[]; /** 앞에서부터 고정할 열 수(기본 0) */ frozen?: number; /** 합계 줄(맨 아래 고정) — 없으면 안 그림 */ total?: DataRow | null; /** 표 이름(화면 읽기용) */ caption?: string; /** 빈 값 글(기본 「-」 · 사용자가 채울 칸은 "") */ empty?: 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, 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 [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, 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 = ""; 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; const tip = esc(c.title ?? c.label); 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, blank = EMPTY, ): string { let tr = ""; for (let j = 0; j < cols.length; 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) .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) : ""; 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 ? `${rowHtml(cols, fmts, left, o.total, false, blank)}` : ""; 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); }, }; }