Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1

This commit is contained in:
2026-10-06 23:18:30 +09:00
7 changed files with 84 additions and 56 deletions
+2 -1
View File
@@ -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);
}
+25 -44
View File
@@ -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<number> {
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;
}
/** 탭 몸통을 그림 — 불러오는 동안 한 줄 · 실패하면 까닭 한 줄. */
@@ -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: "<b>" }], rows: [{ x: "a&<" }] }),
noTotal: m.renderDataTableHtml({ columns: [tc], rows: [] }).includes("<tfoot>"),
}),
@@ -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
+7
View File
@@ -85,3 +85,10 @@ def test_보이는_줄만_그림(r):
'<tr class="is-alt">'
) # 61 번째(홀수) 줄 — 번갈아 색은 전체 줄 번호로
assert 'style="height:21975px"' in w["bottom"] # (1000 - 121) × 25
def test_빈_칸_글_풍선_꾸밈_옵션(r):
o = r["opts"]
assert '<td class="is-empty"></td>' in o # empty="" — 「—」 대신 빈칸
assert '<th scope="col" rowspan="1" class="is-missing" title="풍선">메쌓기</th>' in o
assert 'rowspan="2" class="is-frozen is-edge" style="left:0px" title="측점">측점</th>' in o
+6
View File
@@ -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);
+27 -11
View File
@@ -24,6 +24,10 @@ export interface DataColumn {
decimals?: number;
/** 열 폭 px(기본 숫자 84 · 글 96) */
width?: number;
/** 맨 아래 머리 칸 풍선 글(기본 label) */
title?: string;
/** 맨 아래 머리 칸에 더할 class(예 빠진 항목 빨강) */
className?: string;
}
export type DataRow = Record<string, unknown>;
@@ -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 += `<th scope="col" rowspan="${span}"${num}${stick(j, j)} title="${esc(c.label)}">${esc(c.label)}</th>`;
const tip = esc(c.title ?? c.label);
tr += `<th scope="col" rowspan="${span}"${num}${stick(j, j, c.className)} title="${tip}">${esc(c.label)}</th>`;
}
}
rows.push(`<tr>${tr}</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 ? `<tfoot>${rowHtml(cols, fmts, left, o.total)}</tfoot>` : "";
const foot = o.total ? `<tfoot>${rowHtml(cols, fmts, left, o.total, false, blank)}</tfoot>` : "";
const cap = o.caption ? `<caption>${esc(o.caption)}</caption>` : "";
return (
`<table class="ui-data-table__table" style="width:${x}px">${cap}<colgroup>${colgroup}</colgroup>` +