/* ============================================================================= * B08_Quantity_Summary_Table.ts * 구조물 집계표(PLAN 61-1) 자료 읽기 · 표 짜기 — 화면 없음(PLAN 302-3). * 화면(`B08_Quantity_UI_Summary`) · 인쇄 미리보기(`B09_Draft_Print_Quantity`)가 같은 함수를 씀. * * `loadSummary` = 구조물 · 배관 · 측점 간격 · 배관 집계(서버) → 줄 측점 · 조합 열 · 사본 경로 * `summaryTable` = 공용 표 옵션(머리 · 측점 열 · 조합 열 · 배관 열 · 계 줄 · 램프 칸 class) * 커플링 열 = 좌측 패널 1본 길이로 다시 셈(`view.live()` · 캐시 → 저장본 · PLAN 122) * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import type { DataColumn, DataRow, DataTableOptions } from "@ui/ui_template_data_table"; import { pipeStructureId } from "@ui/ui_template_item_picker_project"; 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 { stationLabel } from "../common_util/common_util_station"; import { summaryTooltip, type SummaryItem } from "../common_util/common_util_summary_item"; import { comboId, linkedOnly, pipeOwnerId, summaryColumns, summaryStations, type ComboColumn, type ComboLabel, type LinkedSnapshot, type PlacedItem, type UnlinkedFacility, unassignedText, unlinkedAt, widthOf, } from "./B08_Quantity_Summary_Data"; import { recountCouplings, type PipeLength } from "./B08_Quantity_Options_Calc"; import { liveOnePipe } 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; } /** 서버 배관 집계(PLAN 98-1) — 열 머리 `group`(구조물 · 형식 · 규격) + `label`(단위) · 열마다 조합 id. */ interface PipeColumn { id: string; group: string[]; label: string; combos: string[]; } export 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[]; /** 연결된 사본 `{구조물 id: 사본}`(143-1) */ linked: Record; /** 미연결 시설(154-1) */ unlinked: UnlinkedFacility[]; /** 모든 시설 칸(모달 「조합 연결」 · 156-1) */ facilities: UnlinkedFacility[]; } 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 = "관 공"; /** 미연결 램프 칸 class(`B08_Quantity_UI_Page.css` · 198-1) · 램프 자리 폭(12px 글 기준) */ export const LAMP_CLASS = "b08-summary__lamp"; /** 모두 연결 = 같은 램프 초록(247-1) */ const LINKED_CLASS = `${LAMP_CLASS} is-linked`; const LAMP_W = 14; 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)); /** 배관 열 → 표 열 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(사본 경로 · 조합 잇기 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), }; }); } /** 집계표 한 벌 — 화면 · 인쇄가 함께 씀 */ export interface SummaryView { stations: number[]; /** 조합 열(횡단배수 = 서버 배관 열이라 뺌) */ columns: ComboColumn[]; /** 모달용 구조물 · 관 지점(사본 있는 것) */ things: PlacedItem[]; linked: Record; describe: (item: SummaryItem) => ComboLabel | null; comboPath: (id: string) => string | null; interval: number; unlinked: UnlinkedFacility[]; saved: PipeSummary | null; /** 배관 집계 실패 까닭 — 나머지 표는 그대로 */ error: string | null; /** 지금 배관 집계 — 커플링 = 좌측 패널 1본 길이(캐시 → 저장본) */ live: () => PipeSummary | null; } export async function loadSummary(projectId: string): Promise { const [structures, pipes, interval, pipe] = await Promise.all([ fetchStructures(projectId), // 배수 분석 전이면 관이 없음 — 구조물만으로 그림 fetchDetailPipePoints(projectId).then( (response) => response.pipe_points, () => [], ), stationInterval(projectId).catch(() => 20), // 배관 집계 실패 = 까닭 한 줄 · 나머지 표는 그대로 fetchPipeSummary(projectId).catch((error: unknown) => String(error)), ]); const saved = typeof pipe === "string" ? null : pipe; // 조합 이름 = 사본 manifest 경로(층 목록을 안 읽음 · 143-1) const linked = saved?.linked ?? {}; const paths = new Map( Object.values(linked).map((copy) => [comboId(copy.항목), copy.경로] as const), ); const describe = (item: SummaryItem): ComboLabel | null => { const [group = "", ...rest] = paths.get(comboId(item)) ?? []; return group ? { group, name: rest.join(" ") || group } : null; }; const comboPath = (id: string): string | null => paths.get(id)?.join(" ") || null; // 관 지점 id = 서버가 낸 id 먼저(응답 측점은 둘째 자리 · id 는 셋째 자리 · 167-1) const ids = [...Object.keys(linked), ...(saved?.facilities ?? []).map((f) => f.id)]; const placed = linkedOnly( pipes.map((point) => ({ ...point, structure_id: pipeOwnerId(point.chainage_m, ids) ?? pipeStructureId(point.chainage_m), })), linked, ); const mine = linkedOnly(structures.structures, linked); const things: PlacedItem[] = [...mine, ...placed]; const unlinked = saved?.unlinked ?? []; // 횡단배수는 서버 집계 열(98 · 120) — 조합 열은 다른 구조물 · 시설만(모달은 그대로 전부) const served = new Set(saved?.types ?? []); const others = [ ...mine.filter((item) => !served.has(item.type_id)), ...placed.filter((point) => !served.has(point.facility ?? "pipe")), ]; return { stations: summaryStations([...things, ...unlinked]), columns: summaryColumns(others, describe), things, linked, describe, comboPath, interval, unlinked, saved, error: typeof pipe === "string" ? pipe : null, live: () => saved && recountCouplings(saved, liveOnePipe(projectId, saved.one_length_m)), }; } /** 표 옵션 — 줄 측점 · 측점 칸 램프 class · labels = 측점 글 · missing = 줄마다 빈 조합 칸 글(호버) */ export function summaryTable(view: SummaryView): { options: DataTableOptions; labels: string[]; missing: string[]; } { const { stations, columns, interval, unlinked, comboPath } = view; const pipe = view.live(); 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"); // 198-1 — 미연결 시설 측점 = 측점 칸 왼쪽 노랑 램프(값 빈칸 · 누르면 바로 연결 · 154) · 호버 = 빈 조합 칸(160-2) const missing = stations.map((station) => unassignedText(unlinkedAt(unlinked, station), L("B08_Quantity_Link_Body")), ); // 247-1 — 시설이 있고 빈 칸 없음 = 초록 램프 const linked = stations.map((station) => unlinkedAt(pipe?.facilities ?? [], station).length > 0); const options: DataTableOptions = { columns: [ { key: "station", label: stationHead, width: widthOf(stationHead, ...labels) + LAMP_W }, ...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, cellClass: (i, key) => key !== "station" ? null : missing[i] ? LAMP_CLASS : linked[i] ? LINKED_CLASS : null, frozen: 1, empty: "", caption: L("B08_Quantity_Tab_StructureSummary"), }; return { options, labels, missing }; }