Files
Aislo/B08_Quantity/B08_Quantity_UI_Summary.ts
T
eomsangdon 46624c026f Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1
# Conflicts:
#	B08_Quantity/B08_Quantity_Router_Summary.py
#	B08_Quantity/B08_Quantity_UI_Page.css
#	B08_Quantity/B08_Quantity_UI_Summary.ts
#	main.py
2026-10-08 18:53:59 +09:00

250 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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}`));
}
}