# Conflicts: # B08_Quantity/B08_Quantity_Router_Summary.py # B08_Quantity/B08_Quantity_UI_Page.css # B08_Quantity/B08_Quantity_UI_Summary.ts # main.py
250 lines
11 KiB
TypeScript
250 lines
11 KiB
TypeScript
/* =============================================================================
|
||
* 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<number> {
|
||
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<string, number> }>;
|
||
total: Record<string, number>;
|
||
notes: string[];
|
||
/** 서버가 펼친 종류(`type_id` · 관 지점은 `facility`) — 조합 열에서 뺌 */
|
||
types: string[];
|
||
one_length_m: number | null;
|
||
pipes: PipeLength[];
|
||
}
|
||
|
||
async function fetchPipeSummary(projectId: string): Promise<PipeSummary> {
|
||
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<string, PickerRow>,
|
||
describe: (item: SummaryItem) => ComboLabel | null,
|
||
): Promise<void> {
|
||
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<void> {
|
||
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}`));
|
||
}
|
||
}
|