Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>` +
|
||||
|
||||
Reference in New Issue
Block a user