/* ============================================================================= * 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` * 횡단배수 열 · 칸 = GET …/quantity/summary(서버 단독 · PLAN 98 · 120) — 머리 구조물 · 형식 · 규격 * 3단 + 단위 줄 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(있으면) · 서버가 펼친 종류(`types`)는 * 조합 열에서 뺌 · 다른 구조물 열은 조합 그대로 * 열 폭 = 맨 아래 단 글(+ 칸 값)만 잼 · 위 단 머리는 줄바꿈(`B08_Quantity_UI_Page.css` · 120-2) * 줄을 누르면 그 측점의 구조물도 모달(PLAN 95-3 · `B08_Quantity_UI_Drawing` 은 누를 때 `import()`) * 커플링 열 = 좌측 패널 1본 길이로 바로 다시 셈(관마다 길이 `pipes` · `recountCouplings` · PLAN 122) * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { loadRows as loadPickerRows } from "@ui/ui_template_item_picker"; import { pathOf, rowId, type PickerRow } from "@ui/ui_template_item_picker_data"; import { showToast } from "@ui/ui_template_elements"; 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, type DataColumn, type DataRow } from "@ui/ui_template_data_table"; import { pipeStructureId } from "@ui/ui_template_item_picker_project"; import { stationLabel } from "../common_util/common_util_station"; import { summaryTooltip, type SummaryItem } from "../common_util/common_util_summary_item"; import { combosAt, comboId, summaryColumns, summaryStations, type ComboColumn, type ComboLabel, type PlacedItem, type StationCombo, widthOf, } from "./B08_Quantity_Summary_Data"; import { recountCouplings, type PipeLength } from "./B08_Quantity_Options_Calc"; import { liveOnePipe, onOptionsChange } from "./B08_Quantity_UI_Options"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } /** 측점 간격 — 노선 설정값 · 못 읽으면 20m. */ export async function stationInterval(projectId: string): Promise { const latest = readRouteLatestCache(projectId) ?? (await fetchLatestRoute(projectId)); return latest.route_params?.station_interval_m || 20; } function note(text: string): HTMLElement { const node = document.createElement("p"); node.className = "ui-general-block__empty"; node.textContent = text; return node; } /** 서버 배관 집계(PLAN 98-1) — 열 머리 `group`(구조물 · 형식 · 규격) + `label`(단위) · 열마다 조합 id. */ interface PipeColumn { id: string; group: string[]; label: string; combos: string[]; } interface PipeSummary { columns: PipeColumn[]; rows: Array<{ chainage_m: number; values: Record }>; total: Record; notes: string[]; /** 서버가 펼친 종류(`type_id` · 관 지점은 `facility`) — 조합 열에서 뺌 */ types: string[]; one_length_m: number | null; pipes: PipeLength[]; } async function fetchPipeSummary(projectId: string): Promise { const response = await fetch(`${API_BASE_URL}/projects/${projectId}/quantity/summary`, { credentials: "include", }); if (!response.ok) throw new Error(`${response.status} ${await response.text()}`); return response.json(); } const PIPE_TOP = "관 공"; const stationKey = (station: number): number => Math.round(station * 100); /** 열 소수 자리 — 칸 · 계 값이 쓰는 만큼(최대 2). */ const placesOf = (values: number[]): number => Math.max(0, ...values.map((v) => (String(Math.round(v * 100) / 100).split(".")[1] ?? "").length)); /** 배관 열 → 표 열 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(M02 에 있으면 · 조합 잇기 98). */ function pipeColumns(summary: PipeSummary, comboPath: (id: string) => string | null): DataColumn[] { const top = summary.columns .filter((column) => column.group[0] === PIPE_TOP) .flatMap((column) => column.combos) .map(comboPath) .find((path) => !!path) ?? PIPE_TOP; return summary.columns.map((column) => { const group = column.group[0] === PIPE_TOP ? [top, ...column.group.slice(1)] : column.group; const values = [ ...summary.rows.map((row) => row.values[column.id]), summary.total[column.id], ].filter((value): value is number => typeof value === "number"); const decimals = placesOf(values); const texts = values.map((value) => value.toLocaleString("en-US", { minimumFractionDigits: decimals, maximumFractionDigits: decimals, }), ); return { key: column.id, label: column.label, group, kind: "number", decimals, title: [...group, column.label].join(" › "), width: widthOf(column.label, ...texts), }; }); } function buildTable( stations: number[], columns: ComboColumn[], pipe: PipeSummary | null, comboPath: (id: string) => string | null, interval: number, onRowClick: (station: number, label: string) => void, ): HTMLElement { const values = new Map((pipe?.rows ?? []).map((row) => [stationKey(row.chainage_m), row.values])); const total: DataRow | null = pipe?.columns.length ? { station: L("B08_Quantity_Summary_Total"), ...pipe.total } : null; const labels = stations.map((station) => stationLabel(station, interval)); const stationHead = L("B08_Quantity_Summary_Station"); const table = createDataTable({ columns: [ { key: "station", label: stationHead, width: widthOf(stationHead, ...labels) }, ...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), })), ...(pipe ? pipeColumns(pipe, comboPath) : []), ], // 조합 열 칸은 아직 비움 — 사용자가 채울 자리라 「-」 대신 빈칸 · 배관 칸 = 서버 값 rows: stations.map((station, i) => ({ station: labels[i], ...values.get(stationKey(station)), })), total, onRowClick: (i) => onRowClick(stations[i], labels[i]), frozen: 1, empty: "", caption: L("B08_Quantity_Tab_StructureSummary"), }); table.root.dataset.b08Summary = "table"; return table.root; } /** 행 누름 — 카드 · CAD 묶음은 이때 처음 받음. */ async function openSheets( projectId: string, station: string, combos: StationCombo[], rows: Map, describe: (item: SummaryItem) => ComboLabel | null, ): Promise { try { const { openStructureSheets } = await import("./B08_Quantity_UI_Drawing"); openStructureSheets({ projectId, station, combos, rows, describe }); } catch (error) { showToast(`${L("B08_Quantity_Drawing_Failed")} — ${String(error)}`, "error"); } } /** 탭 몸통을 그림 — 불러오는 동안 한 줄 · 실패하면 까닭 한 줄. */ export async function renderStructureSummary(host: HTMLElement, projectId: string): Promise { host.replaceChildren(note(L("B08_Quantity_Summary_Loading"))); try { const [structures, pickerRows, pipes, interval, pipe] = await Promise.all([ fetchStructures(projectId), loadPickerRows(), // 배수 분석 전이면 관이 없음 — 구조물만으로 그림 fetchDetailPipePoints(projectId).then( (response) => response.pipe_points, () => [], ), stationInterval(projectId).catch(() => 20), // 배관 집계 실패 = 까닭 한 줄 · 나머지 표는 그대로 fetchPipeSummary(projectId).catch((error: unknown) => String(error)), ]); const m02 = new Map(pickerRows.map((row) => [rowId(row), row])); const describe = (item: SummaryItem): ComboLabel | null => { const row = m02.get(comboId(item)); if (!row) return null; const [group = "", ...rest] = pathOf(row); return { group, name: rest.join(" ") || group || row.key }; }; const comboPath = (id: string): string | null => { const row = m02.get(id); return row ? pathOf(row).join(" ") : null; }; const placed = pipes.map((point) => ({ ...point, structure_id: pipeStructureId(point.chainage_m), })); const things: PlacedItem[] = [...structures.structures, ...placed]; const stations = summaryStations(things); // 횡단배수는 서버 집계 열(98 · 120) — 조합 열은 다른 구조물 · 시설만(모달은 그대로 전부) const saved = typeof pipe === "string" ? null : pipe; const served = new Set(saved?.types ?? []); const others = [ ...structures.structures.filter((item) => !served.has(item.type_id)), ...placed.filter((point) => !served.has(point.facility ?? "pipe")), ]; const columns = summaryColumns(others, describe); const live = (): PipeSummary | null => saved && recountCouplings(saved, liveOnePipe(projectId, saved.one_length_m)); const summary = live(); const parts: HTMLElement[] = []; if (typeof pipe === "string") parts.push(note(`${L("B08_Quantity_Summary_Failed")} — ${pipe}`)); for (const text of summary?.notes ?? []) parts.push(note(text)); if (!columns.length && !summary?.columns.length) parts.push(note(L("B08_Quantity_Summary_NoCombo"))); const draw = (): HTMLElement => stations.length ? buildTable(stations, columns, live(), comboPath, interval, (station, label) => openSheets(projectId, label, combosAt(things, station), m02, describe), ) : note(L("B08_Quantity_Summary_NoStation")); let table = draw(); parts.push(table); host.replaceChildren(...parts); onOptionsChange(host, () => { const next = draw(); table.replaceWith(next); table = next; }); } catch (error) { const reason = error instanceof Error ? error.message : String(error); host.replaceChildren(note(`${L("B08_Quantity_Summary_Failed")} — ${reason}`)); } }