/* ============================================================================= * 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 } * 고칠 칸(PLAN 121) = editable(줄, 열) 참인 칸만 글 상자 · 바꾸면 onEdit(줄, 열, 글) — 다시 그려도 커서 자리 지킴 * 칸 누름(PLAN 144) = onCellClick(줄, 열) · 줄로 이동 = t.focusRow(줄)(스크롤 + 강조) * 줄 꼴(PLAN 155-2) = rowClass(줄) → 줄 class(예 is-title 굵게) · 안 주면 글 그대로 * 창 끝까지(PLAN 199) = fillViewport → 표 칸 최대 높이를 창 아래 끝까지(줄이 늘어도 페이지 스크롤 0) * 레벨(PLAN 201-1 · 보기 전용) = level { key, of(줄) } → 그 열 왼쪽 맞춤 + 레벨마다 1em 들여쓰기(글 상자도) * 같은 값 세로 병합(PLAN 201-1) = mergeSame [열] → 위아래로 같은 값이 이어지면 한 칸(rowSpan) · 본문만 * 앞 병합 열이 갈리면 뒤 병합 열도 갈림 · 빈 값 · 고칠 칸은 안 합침 · 칸 → 열 = `cellColumn`(병합 표는 data-c) * ========================================================================== */ import "./ui_template_data_table.css"; export interface DataColumn { /** 줄 객체에서 값을 꺼낼 키 */ key: string; /** 맨 아래 머리 글 */ label: string; /** 묶음 머리(위 → 아래) — 이웃 열과 앞부분이 같으면 한 칸으로 합침 */ group?: string[]; /** 기본 글 · 숫자면 오른쪽 맞춤 · 천 단위 쉼표 */ kind?: "number" | "text"; /** 숫자 소수 자리(기본 0) */ decimals?: number; /** 끝 0 지움(decimals 는 최대 자리) — 사용자가 넣는 값처럼 자리가 줄마다 다른 열 */ trim?: boolean; /** 열 폭 px(12px 글 기준 · 기본 숫자 84 · 글 96) — 그릴 때 글 비 SCALE 을 곱함 */ 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; /** 줄 누름(본문 줄만 · 합계 제외) — 주면 줄에 `data-i`(줄 번호)·`is-clickable` 이 붙음 */ onRowClick?: (index: number) => void; /** 칸 누름(본문 줄만) — 주면 줄에 `data-i`·`is-clickable` 이 붙음 · 줄 누름보다 먼저 부름 */ onCellClick?: (index: number, key: string) => void; /** 강조할 줄(`focusRow` 가 정함) */ focus?: number | null; /** 고칠 칸인지(본문 줄만 · 합계 제외) — 참이면 칸이 글 상자 */ editable?: (index: number, key: string) => boolean; /** 고친 칸 글(바꾼 뒤 · 커서가 옮겨 간 다음에 부름) */ onEdit?: (index: number, key: string, text: string) => void; /** 줄 꼴 class(본문 줄만 · 예 `is-title` = 굵게) — 빈 값이면 안 붙임 */ rowClass?: (index: number) => string | null | undefined; /** 칸 꼴 class(본문 줄 · 글 상자 아닌 칸만 · 예 `is-picked` = 고른 칸 강조) — 빈 값이면 안 붙임 */ cellClass?: (index: number, key: string) => string | null | undefined; /** 참이면 표 칸 최대 높이 = 창 아래 끝까지(창 크기 바뀌면 다시 셈) — 안 주면 CSS 고정값 */ fillViewport?: boolean; /** 레벨(보기 전용) — key 열을 레벨마다 1em 들여씀 · of = 줄 레벨(1 = 맨 위 · 빈 값 = 1) */ level?: { key: string; of: (index: number) => number | null | undefined }; /** 같은 값 세로 병합할 열 키(표마다 지정) — 위아래로 같은 글이 이어지면 rowSpan 한 칸 */ mergeSame?: string[]; } export interface DataTable { root: HTMLElement; setRows: (rows: DataRow[], total?: DataRow | null) => void; /** 그 줄로 스크롤하고 강조 */ focusRow: (index: number) => void; } const EMPTY = "-"; const ESC: Record = { "&": "&", "<": "<", ">": ">", '"': """ }; const esc = (s: string): string => s.replace(/[&<>"]/g, (c) => ESC[c]); /** 글 크기 비(14px ÷ 12px) — 페이지가 준 열 폭(12px 글 기준)을 같은 비율로 넓힘 */ export const SCALE = 14 / 12; function colWidth(c: DataColumn): number { return Math.round((c.width ?? (c.kind === "number" ? 84 : 96)) * SCALE); } /** 값 한 칸 → [보일 글, 빈 값인지] */ 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, index: number | null = null, edit: number | null = null, editable?: (index: number, key: string) => boolean, focus = false, extra: string | null | undefined = null, cellClass?: (key: string) => string | null | undefined, more: RowMore = {}, ): string { let tr = ""; const { lv, spans } = more; for (let j = 0; j < cols.length; j++) { const key = cols[j].key; const span = spans?.[j] ?? 1; if (!span) continue; // 위 칸에 합쳐짐 const lvc = lv?.key === key ? " ui-data-table__lv" : ""; const c = spans ? ` data-c="${j}"` : ""; if (edit !== null && editable?.(edit, key)) { const v = r[key]; const raw = v === null || v === undefined ? "" : String(v); const tip = esc(cols[j].title ?? cols[j].label); tr += ``; continue; } const [txt, empty] = formatCell(r[key], fmts[j], blank); const frz = left[j] !== null; const cls = [ frz && "is-frozen", frz && left[j + 1] == null && "is-edge", empty && "is-empty", cellClass?.(key), lvc.trim(), span > 1 && "is-merged", ] .filter(Boolean) .join(" "); const attrs = (cls ? ` class="${cls}"` : "") + (frz ? ` style="left:${left[j]}px"` : "") + (fmts[j] ? ' data-num=""' : "") + (span > 1 ? ` rowspan="${span}"` : "") + c; tr += `${esc(txt)}`; } const cls = [alt && "is-alt", index !== null && "is-clickable", focus && "is-focus", extra] .filter(Boolean) .join(" "); const data = (index !== null ? ` data-i="${index}"` : "") + (lv ? ` style="--dt-lv:${lv.n}"` : ""); return cls || data ? `${tr}` : `${tr}`; } /** 줄 곁값 — lv = 레벨 열 · 들여쓸 칸 수 · spans = 열마다 rowSpan(0 = 위 칸에 합쳐짐) */ interface RowMore { lv?: { key: string; n: number }; spans?: number[]; } /** 칸 → 열 번호 — 병합 표는 칸이 빠져 cellIndex 가 밀리므로 data-c */ export function cellColumn(cell: HTMLTableCellElement): number { return cell.dataset.c !== undefined ? Number(cell.dataset.c) : cell.cellIndex; } /** 병합 구간(from ~ to 줄) — [줄][열] rowSpan · 0 = 위 칸에 합쳐짐 · 병합 안 하는 열 = 1 */ export function mergeSpans(o: DataTableOptions, from: number, to: number): number[][] { const cols = o.columns; const keys = new Set(o.mergeSame ?? []); const out = Array.from({ length: to - from }, () => cols.map(() => 1)); // 이음 = 이 열까지 앞 병합 열이 모두 위 줄과 같은 글(빈 값 · 고칠 칸 빼고) const shown = (i: number, j: number): string | null => { const key = cols[j].key; if (o.editable?.(i, key)) return null; const [txt] = formatCell(o.rows[i][key], null, ""); return txt.trim() ? txt : null; // 빈 값 · 빈칸 글(" ")은 안 합침 }; for (let i = from + 1; i < to; i++) { let joined = true; for (let j = 0; j < cols.length; j++) { if (!keys.has(cols[j].key)) continue; const v = shown(i, j); joined = joined && v !== null && v === shown(i - 1, j); if (!joined) continue; let top = i - 1; while (!out[top - from][j]) top--; out[top - from][j]++; out[i - from][j] = 0; } } return out; } /** 그릴 줄 구간 — 밖의 줄은 빈 줄 높이(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.trim ? 0 : (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; const pick = o.onRowClick !== undefined || o.onCellClick !== undefined; const spans = o.mergeSame?.length ? mergeSpans(o, from, to) : null; for (let i = from; i < to; i++) { const lvN = o.level ? Math.max(0, (o.level.of(i) ?? 1) - 1) : 0; body += rowHtml( cols, fmts, left, o.rows[i], i % 2 === 1, blank, pick ? i : null, i, o.editable, o.focus === i, o.rowClass?.(i), o.cellClass ? (key) => o.cellClass?.(i, key) : undefined, { ...(o.level ? { lv: { key: o.level.key, n: lvN } } : {}), ...(spans ? { spans: spans[i - from] } : {}), }, ); } 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; /* ── 창 아래 끝까지(PLAN 199) — 칸 최대 높이 = 창 높이 − 칸 위쪽 자리 − 아래 자리 ──────────── * 아래 자리 = 칸 뒤 형제 요소 높이 + 윗대 요소의 아래 안쪽 여백 · 테두리 · 바깥 여백. * 줄이 적으면 내용만큼 · 창 크기 · 위아래 요소가 바뀌면 다시 셈 · 칸이 문서에서 떨어지면 스스로 멈춤. * ------------------------------------------------------------------------------------------ */ /** 칸이 이보다 작아지지 않게(아주 작은 창에서 머리만 남는 것 방지) */ const MIN_HEIGHT = 120; /** 창 기준 최대 높이(px) — `top` = 문서 맨 위에서 칸 위까지 · `below` = 칸 아래로 남겨야 할 자리 */ export function fillHeight(viewport: number, top: number, below: number): number { return Math.max(MIN_HEIGHT, Math.floor(viewport - top - below)); } const px = (v: string): number => parseFloat(v) || 0; /** 칸 아래로 문서 끝까지 차지하는 자리 */ export function spaceBelow(el: HTMLElement): number { let sum = 0; for (let node: HTMLElement = el; node.parentElement; node = node.parentElement) { const parent = node.parentElement; const own = getComputedStyle(node); const around = getComputedStyle(parent); const gap = px(around.rowGap); for (let s = node.nextElementSibling; s; s = s.nextElementSibling) { const h = s.getBoundingClientRect().height; if (h) sum += h + gap; } sum += px(own.marginBottom) + px(around.paddingBottom) + px(around.borderBottomWidth); } return sum; } export function fillViewport(el: HTMLElement, onFit?: () => void): () => void { let seen = false; // 한 번이라도 문서에 붙었는지 — 붙었다가 떨어지면 멈춤 let observer: ResizeObserver | null = null; const fit = (): void => { if (!el.isConnected) { if (seen) stop(); return; } seen = true; const top = el.getBoundingClientRect().top + window.scrollY; el.style.maxHeight = `${fillHeight(window.innerHeight, top, spaceBelow(el))}px`; onFit?.(); }; const stop = (): void => { window.removeEventListener("resize", fit); observer?.disconnect(); }; window.addEventListener("resize", fit); // 칸이 붙을 때 · 줄이 늘 때 · 위아래 요소가 바뀔 때(문서 높이) 다시 셈 observer = new ResizeObserver(fit); observer.observe(el); observer.observe(document.body); return stop; } 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: 28 }; // --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 }; // 고치던 칸 — 다시 그린 뒤 같은 칸에 커서를 돌려 놓음(Tab 으로 옮겨 간 칸 포함) const active = document.activeElement; const focus = active instanceof HTMLInputElement && root.contains(active) ? `input[data-i="${active.dataset.i}"][data-k="${CSS.escape(active.dataset.k ?? "")}"]` : null; root.innerHTML = renderDataTableHtml(state, win); if (focus) root.querySelector(focus)?.focus(); // 줄 높이는 그린 줄을 재서 맞춤(글꼴 · 토큰이 바뀌어도) 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 }); // 바꾼 칸 — 커서가 다음 칸으로 옮겨 간 뒤 알림(그 사이 다시 그리면 Tab 이 갈 곳을 잃음) root.addEventListener("change", (event) => { const input = event.target as HTMLInputElement; if (!input.dataset?.k) return; const index = Number(input.dataset.i); const key = input.dataset.k; setTimeout(() => state.onEdit?.(index, key, input.value)); }); root.addEventListener("click", (event) => { const row = (event.target as Element).closest("tr[data-i]"); if (!row || !root.contains(row)) return; const cell = (event.target as Element).closest("td"); const column = cell ? state.columns[cellColumn(cell)] : undefined; if (column) state.onCellClick?.(Number(row.dataset.i), column.key); state.onRowClick?.(Number(row.dataset.i)); }); draw(true); if (opts.fillViewport) fillViewport(root, () => draw(false)); return { root, setRows(rows, total) { state.rows = rows; if (total !== undefined) state.total = total; draw(true); }, focusRow(index) { state.focus = index; root.scrollTop = Math.max(0, index * win.rowH - root.clientHeight / 3); draw(true); }, }; }