From 53d839cb1d806d341d6dc269ee3bdfbfd6c257ab Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 23:15:42 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(B08):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EC=A7=91=EA=B3=84=ED=91=9C=EB=A5=BC=20=EA=B3=B5=EC=9A=A9=20?= =?UTF-8?q?=EB=A7=8E=EC=9D=80=20=EC=9E=90=EB=A3=8C=20=ED=91=9C=EB=A1=9C(70?= =?UTF-8?q?-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 집계표 = createDataTable — 머리 두 줄(조합 구분 · 이름) · 측점 열 고정 · 칸은 빈칸 · 빠진 조합 빨강 · 풍선 글 그대로 - 공용 표 하위 호환 옵션 — empty(빈 값 글) · 열 title · className · 빠진 항목 머리 색 · 틀 폭은 표만큼 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- B08_Quantity/B08_Quantity_UI_Summary.ts | 69 +++++++------------ resources/tester/helper_ui_data_table.cjs | 9 +++ .../tester/test_61_1_b08_summary_table.py | 8 +++ resources/tester/test_ui_data_table.py | 7 ++ ui_template/ui_template_data_table.css | 6 ++ ui_template/ui_template_data_table.ts | 38 +++++++--- 6 files changed, 82 insertions(+), 55 deletions(-) diff --git a/B08_Quantity/B08_Quantity_UI_Summary.ts b/B08_Quantity/B08_Quantity_UI_Summary.ts index 2b9e507bc..78b456d94 100644 --- a/B08_Quantity/B08_Quantity_UI_Summary.ts +++ b/B08_Quantity/B08_Quantity_UI_Summary.ts @@ -1,6 +1,7 @@ /* ============================================================================= * B08_Quantity_UI_Summary.ts * 1탭 구조물 집계표(PLAN 61-1) — 줄 = 측점 · 열 = 저장된 구조물 조합 · 칸은 아직 비움. + * 표는 공용 많은 자료 표(`ui_template_data_table` · PLAN 70-2) — 머리 · 측점 열 고정. * * 구조물 = GET …/route/structures · 배관 = GET …/drainage/pipe-points (읽기만) * 머리 두 줄 = 조합 구분(M02 경로 첫 칸) · 조합 이름(나머지 경로) — `GET /api/m02/items?all=1` @@ -12,11 +13,11 @@ import { pathOf, rowId } from "@ui/ui_template_item_picker_data"; import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures"; import { fetchLatestRoute, readRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch"; import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { createDataTable } from "@ui/ui_template_data_table"; import { stationLabel } from "../common_util/common_util_station"; import { summaryTooltip } from "../common_util/common_util_summary_item"; import { comboId, - groupSpans, summaryColumns, summaryStations, type ComboColumn, @@ -33,13 +34,6 @@ export async function stationInterval(projectId: string): Promise { return latest.route_params?.station_interval_m || 20; } -function cell(tag: "th" | "td", text = "", className = ""): HTMLTableCellElement { - const node = document.createElement(tag); - node.textContent = text; - if (className) node.className = className; - return node; -} - function note(text: string): HTMLElement { const node = document.createElement("p"); node.className = "ui-general-block__empty"; @@ -47,43 +41,30 @@ function note(text: string): HTMLElement { return node; } -function buildTable(stations: number[], columns: ComboColumn[], interval: number): HTMLElement { - const head = cell("th", L("B08_Quantity_Summary_Station"), "b08-summary__station"); - head.rowSpan = 2; - const groups = document.createElement("tr"); - groups.append(head); - for (const [group, span] of groupSpans(columns)) { - const th = cell("th", group || L("B08_Quantity_Summary_MissingGroup"), "b08-summary__group"); - th.colSpan = span; - groups.append(th); - } - const names = document.createElement("tr"); - for (const column of columns) { - const th = cell("th", column.name, "b08-summary__name"); - th.dataset.b08Combo = column.id; - th.title = summaryTooltip(column.item); - if (column.missing) th.classList.add("is-missing"); - names.append(th); - } - const thead = document.createElement("thead"); - thead.append(groups, names); +/** 머리 글 길이에 맞춘 열 폭 — 칸은 비어 있어 글이 폭을 정함. */ +const widthOf = (label: string): number => Math.min(200, Math.max(96, label.length * 13 + 24)); - const tbody = document.createElement("tbody"); - for (const station of stations) { - const tr = document.createElement("tr"); - tr.dataset.b08Station = String(station); - tr.append(cell("th", stationLabel(station, interval), "b08-summary__station")); - for (const _ of columns) tr.append(cell("td")); - tbody.append(tr); - } - const table = document.createElement("table"); - table.className = "ui-general-block__table b08-summary__table"; - table.dataset.b08Summary = "table"; - table.append(thead, tbody); - const wrap = document.createElement("div"); - wrap.className = "ui-general-block__table-wrap b08-summary__wrap"; - wrap.append(table); - return wrap; +function buildTable(stations: number[], columns: ComboColumn[], interval: number): HTMLElement { + const table = createDataTable({ + columns: [ + { key: "station", label: L("B08_Quantity_Summary_Station"), width: 96 }, + ...columns.map((column) => ({ + key: column.id, + label: column.name, + group: [column.group || L("B08_Quantity_Summary_MissingGroup")], + title: summaryTooltip(column.item), + className: column.missing ? "is-missing" : undefined, + width: widthOf(column.name), + })), + ], + // 칸은 아직 비움 — 사용자가 채울 자리라 「—」 대신 빈칸 + rows: stations.map((station) => ({ station: stationLabel(station, interval) })), + frozen: 1, + empty: "", + caption: L("B08_Quantity_Tab_StructureSummary"), + }); + table.root.dataset.b08Summary = "table"; + return table.root; } /** 탭 몸통을 그림 — 불러오는 동안 한 줄 · 실패하면 까닭 한 줄. */ diff --git a/resources/tester/helper_ui_data_table.cjs b/resources/tester/helper_ui_data_table.cjs index aae5bae00..1b7ddf2db 100644 --- a/resources/tester/helper_ui_data_table.cjs +++ b/resources/tester/helper_ui_data_table.cjs @@ -73,6 +73,15 @@ console.log( .filter(Boolean); return { n: b.length, top: b[0], second: b[1].slice(0, 20), bottom: b[b.length - 1] }; })(), + opts: m.renderDataTableHtml({ + columns: [ + { key: "sta", label: "측점" }, + { key: "c1", label: "메쌓기", group: ["기슭막이"], title: "풍선", className: "is-missing" }, + ], + rows: [{ sta: "0+0" }], + frozen: 1, + empty: "", + }), escaped: m.renderDataTableHtml({ columns: [{ key: "x", label: "" }], rows: [{ x: "a&<" }] }), noTotal: m.renderDataTableHtml({ columns: [tc], rows: [] }).includes(""), }), diff --git a/resources/tester/test_61_1_b08_summary_table.py b/resources/tester/test_61_1_b08_summary_table.py index bc78cef14..fd1379584 100644 --- a/resources/tester/test_61_1_b08_summary_table.py +++ b/resources/tester/test_61_1_b08_summary_table.py @@ -70,3 +70,11 @@ def test_빈_목록(): " summaryColumns([{ chainage_m: 5 }], () => null), groupSpans([])]));" ) assert out == [[], [], []] + + +def test_집계표는_공용_표(): + # PLAN 70-2 — 공용 많은 자료 표 · 측점 열 고정 · 칸은 빈칸(「—」 아님) + ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + assert "createDataTable(" in ui and "@ui/ui_template_data_table" in ui + assert "frozen: 1" in ui and 'empty: ""' in ui + assert 'document.createElement("table")' not in ui diff --git a/resources/tester/test_ui_data_table.py b/resources/tester/test_ui_data_table.py index 1b32696f0..8ada6fcaa 100644 --- a/resources/tester/test_ui_data_table.py +++ b/resources/tester/test_ui_data_table.py @@ -85,3 +85,10 @@ def test_보이는_줄만_그림(r): '' ) # 61 번째(홀수) 줄 — 번갈아 색은 전체 줄 번호로 assert 'style="height:21975px"' in w["bottom"] # (1000 - 121) × 25 + + +def test_빈_칸_글_풍선_꾸밈_옵션(r): + o = r["opts"] + assert '' in o # empty="" — 「—」 대신 빈칸 + assert '메쌓기' in o + assert 'rowspan="2" class="is-frozen is-edge" style="left:0px" title="측점">측점' in o diff --git a/ui_template/ui_template_data_table.css b/ui_template/ui_template_data_table.css index cf9b36aec..251a5aa0f 100644 --- a/ui_template/ui_template_data_table.css +++ b/ui_template/ui_template_data_table.css @@ -10,6 +10,7 @@ --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)); + width: fit-content; /* 표가 좁으면 틀도 표만큼 */ max-width: 100%; max-height: calc(100vh - 260px); overflow: auto; @@ -78,6 +79,11 @@ text-align: center; } +/* 머리 칸 상태 — 원본에 없는 항목 */ +.ui-data-table__table thead th.is-missing { + color: var(--color-danger); +} + .ui-data-table__table thead tr:last-child th, .ui-data-table__table thead th[rowspan] { border-bottom-color: var(--dt-edge); diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts index c39f2c1a7..0ef3c3c7d 100644 --- a/ui_template/ui_template_data_table.ts +++ b/ui_template/ui_template_data_table.ts @@ -24,6 +24,10 @@ export interface DataColumn { decimals?: number; /** 열 폭 px(기본 숫자 84 · 글 96) */ width?: number; + /** 맨 아래 머리 칸 풍선 글(기본 label) */ + title?: string; + /** 맨 아래 머리 칸에 더할 class(예 빠진 항목 빨강) */ + className?: string; } export type DataRow = Record; @@ -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 += `${esc(c.label)}`; + const tip = esc(c.title ?? c.label); + tr += `${esc(c.label)}`; } } rows.push(`${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 ? `${rowHtml(cols, fmts, left, o.total)}` : ""; + const foot = o.total ? `${rowHtml(cols, fmts, left, o.total, false, blank)}` : ""; const cap = o.caption ? `${esc(o.caption)}` : ""; return ( `${cap}${colgroup}` + From e8ba56ae3949f7c31988c73efbb53979b2ec07d9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 23:15:43 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(B08):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=EB=8F=84=20=EA=B3=A0=EB=A5=B8=20=EB=B3=B4=EC=A1=B0=20=ED=83=AD?= =?UTF-8?q?=EC=9D=98=20=EB=B9=A0=EC=A7=84=20=EC=A1=B0=ED=95=A9=20=EA=B8=80?= =?UTF-8?q?=EC=83=89=20=EB=8C=80=EB=B9=84(70-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 빨간 글은 비어 있는 단추만 — 고른 단추 바탕 위 대비 밝음 3.1 · 어두움 1.4 → 16.6 · 8 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- B08_Quantity/B08_Quantity_UI_Drawing.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/B08_Quantity/B08_Quantity_UI_Drawing.css b/B08_Quantity/B08_Quantity_UI_Drawing.css index f72ba222e..716d0fb64 100644 --- a/B08_Quantity/B08_Quantity_UI_Drawing.css +++ b/B08_Quantity/B08_Quantity_UI_Drawing.css @@ -1,6 +1,7 @@ /* B08 2탭 구조물도(PLAN 61-2) — 보조 탭 · 카드 둘(도면 · 면적산출). 색은 테마 변수만. */ -.b08-drawing__tabs .is-missing { +/* M02 에 없는 조합 — 빨간 글은 비어 있는 단추만(고른 단추 바탕 위에선 안 읽힘 · 아래 안내 글이 알림) */ +.b08-drawing__tabs .is-missing:not(.ui-btn--filled) { color: var(--color-danger); }