Files
Aislo/B08_Quantity/B08_Quantity_UI_Earthwork.ts
T

325 lines
12 KiB
TypeScript

/* =============================================================================
* B08_Quantity_UI_Earthwork.ts
* 토공 탭(PLAN 61-3 · 69-2 · 69-3) — 보조 탭 넷 = 토공집계표 · 토적표 · 평균운반거리 · 종방향 무대운반거리.
*
* GET …/quantity/earthwork 한 번(서버 단독 · 읽기만) — 네 장이 같은 응답을 나눠 그림.
* 토적표 = 줄 측점 · 칸 = 사용자 견본(절토 토사 · 암석 · 측구터파기 토사 · 암석 각 단면적 · 입적 ·
* 보정량 · 보정량계 · 성토 · 유용토 · 차인토량 · 누가토량 · 면고르기 · 지장목제거 · 제근 · 노면정리 각
* 거리 · 면적) · 맨 아래 합계 줄. 첫 측점은 앞이 없어 입적 · 면적이 「-」.
* 표 모양은 1탭 집계표 것(`b08-summary__*`)을 다시 씀 · 나머지 셋은 `B08_Quantity_UI_Earthwork_Sheets`.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { createButton, createCard } from "@ui/ui_template_elements";
import { stationLabel } from "../common_util/common_util_station";
import { stationInterval } from "./B08_Quantity_UI_Summary";
import {
renderEarthworkSummary,
renderFreeHaul,
renderHaulDistance,
} from "./B08_Quantity_UI_Earthwork_Sheets";
type Face = { length_m: number | null; area_m2: number | null };
interface EarthworkRow {
chainage_m: number;
distance_m: number;
cut_soil_area_m2: number;
cut_soil_volume_m3: number;
cut_soil_adjusted_m3: number;
cut_rock_area_m2: number;
cut_rock_volume_m3: number;
cut_rock_adjusted_m3: number;
cut_rock_kind: string;
ditch_soil_area_m2: number;
ditch_soil_volume_m3: number;
ditch_soil_adjusted_m3: number;
ditch_rock_area_m2: number;
ditch_rock_volume_m3: number;
ditch_rock_adjusted_m3: number;
adjusted_total_m3: number;
fill_area_m2: number;
fill_volume_m3: number;
diverted_m3: number;
balance_m3: number;
cumulative_m3: number;
notes: string[];
faces: Record<string, Face>;
}
export interface HaulRow {
q: number;
l: number;
ql: number;
}
export interface HaulList {
rows: HaulRow[];
q: number;
ql: number;
average_m: number | null;
}
export interface SummaryRow {
group: string;
kind: string;
spec: string;
unit: string;
value: number | null;
note: string;
}
export interface EarthworkTable {
rows: EarthworkRow[];
totals: Record<string, number>;
conversion_factors: Record<string, { compacted: number }>;
station_count: number;
route_id: number;
confirmed: boolean;
summary: SummaryRow[];
haul: Record<"dump_truck" | "dozer" | "free_haul", HaulList>;
haul_saved: boolean;
}
const ROCK_KIND_LABEL: Record<string, string> = { ripping_rock: "리핑암", blasting_rock: "발파암" };
const TRIPLE = ["단면적", "입적", "보정량"];
const FACE_GROUPS: Array<[string, Array<[string, string]>]> = [
[
"면고르기",
[
["grading_fill", "성토면"],
["grading_cut", "절토면"],
],
],
[
"지장목제거",
[
["tree_removal_fill", "성토"],
["tree_removal_cut", "절토"],
],
],
["제근", [["grubbing", ""]]],
["노면정리", [["road_surface", ""]]],
];
const FACE_KEYS = FACE_GROUPS.flatMap(([, faces]) => faces.map(([key]) => key));
const DASH = "-";
const num = (value: number | undefined | null): string =>
value == null || !Number.isFinite(value) ? "" : value.toFixed(2);
export function cell(tag: "th" | "td", text: string, span?: { row?: number; col?: number }) {
const node = document.createElement(tag);
node.textContent = text;
if (span?.row) node.rowSpan = span.row;
if (span?.col) node.colSpan = span.col;
return node;
}
export function note(text: string): HTMLElement {
const p = document.createElement("p");
p.className = "ui-general-block__empty";
p.textContent = text;
return p;
}
/** 표 한 장 — 1탭 집계표 모양 · 공용 컨테이너(카드) 안 */
export function tableCard(
info: HTMLElement[],
className: string,
parts: HTMLElement[],
): HTMLElement {
const el = document.createElement("table");
el.className = `ui-general-block__table b08-summary__table ${className}`;
el.append(...parts);
const wrap = document.createElement("div");
wrap.className = "ui-general-block__table-wrap b08-summary__wrap";
wrap.append(el);
return createCard({ body: [...info, wrap] });
}
/** 머리 석 줄 — 견본 차례 · 32칸 */
function head(): HTMLTableSectionElement {
const station = cell("th", "측점", { row: 3 });
station.classList.add("b08-summary__station");
const top = document.createElement("tr");
top.append(
station,
cell("th", "거리", { row: 3 }),
cell("th", "절토", { col: 6 }),
cell("th", "측구터파기", { col: 6 }),
cell("th", "보정량계", { row: 3 }),
cell("th", "성토", { col: 2, row: 2 }),
cell("th", "유용토", { row: 3 }),
cell("th", "차인토량", { row: 3 }),
cell("th", "누가토량", { row: 3 }),
...FACE_GROUPS.map(([label, faces]) =>
cell("th", label, { col: faces.length * 2, row: faces.length > 1 ? 1 : 2 }),
),
);
const mid = document.createElement("tr");
for (let i = 0; i < 2; i += 1)
mid.append(cell("th", "토사", { col: 3 }), cell("th", "암석", { col: 3 }));
for (const [, faces] of FACE_GROUPS) {
if (faces.length > 1) mid.append(...faces.map(([, label]) => cell("th", label, { col: 2 })));
}
const sub = document.createElement("tr");
for (let i = 0; i < 4; i += 1) sub.append(...TRIPLE.map((t) => cell("th", t)));
sub.append(cell("th", "단면적"), cell("th", "입적"));
for (const _ of FACE_KEYS) sub.append(cell("th", "거리"), cell("th", "면적"));
const thead = document.createElement("thead");
thead.append(top, mid, sub);
return thead;
}
function bodyRow(row: EarthworkRow, first: boolean, interval: number): HTMLTableRowElement {
const tr = document.createElement("tr");
tr.dataset.b08Earthwork = String(row.chainage_m);
const station = cell(
"th",
stationLabel(row.chainage_m, interval) + (row.notes.length ? " *" : ""),
);
station.className = "b08-summary__station";
if (row.notes.length) station.title = row.notes.join("\n");
// 첫 측점은 앞이 없어 입적 · 보정량 · 면적이 없음(견본 「-」)
const v = (value: number): string => (first ? DASH : num(value));
const triple = (area: number, volume: number, adjusted: number) => [
num(area),
v(volume),
v(adjusted),
];
const values = [
num(row.distance_m),
...triple(row.cut_soil_area_m2, row.cut_soil_volume_m3, row.cut_soil_adjusted_m3),
...triple(row.cut_rock_area_m2, row.cut_rock_volume_m3, row.cut_rock_adjusted_m3),
...triple(row.ditch_soil_area_m2, row.ditch_soil_volume_m3, row.ditch_soil_adjusted_m3),
...triple(row.ditch_rock_area_m2, row.ditch_rock_volume_m3, row.ditch_rock_adjusted_m3),
v(row.adjusted_total_m3),
num(row.fill_area_m2),
v(row.fill_volume_m3),
v(row.diverted_m3),
v(row.balance_m3),
v(row.cumulative_m3),
...FACE_KEYS.flatMap((key) => {
const face = row.faces?.[key];
return [num(face?.length_m), first ? DASH : num(face?.area_m2)];
}),
];
const cells = values.map((text) => cell("td", text));
// 암석 칸 — 어느 갈래(환산계수)로 셌는지 마우스를 올려 봄
if (row.cut_rock_area_m2 > 0) cells[4].title = ROCK_KIND_LABEL[row.cut_rock_kind] ?? "";
tr.append(station, ...cells);
return tr;
}
/** 합계 줄 — 단면적 · 거리는 합이 뜻이 없어 비움(옛 엔진 `totals` 와 같음) · 누가토량 = 마지막 줄 */
function totalRow(table: EarthworkTable): HTMLTableRowElement {
const t = table.totals;
const last = table.rows[table.rows.length - 1];
const triple = (key: string) => ["", num(t[`${key}_volume_m3`]), num(t[`${key}_adjusted_m3`])];
const values = [
num(t.distance_m),
...triple("cut_soil"),
...triple("cut_rock"),
...triple("ditch_soil"),
...triple("ditch_rock"),
num(t.adjusted_total_m3),
"",
num(t.fill_volume_m3),
num(t.diverted_m3),
num(t.balance_m3),
num(last?.cumulative_m3),
// 값이 하나도 없는 칸(제근 — B07 에 값 없음)은 합계도 비움
...FACE_KEYS.flatMap((key) => [
"",
table.rows.some((row) => row.faces?.[key]?.area_m2 != null) ? num(t[`${key}_area_m2`]) : "",
]),
];
const tr = document.createElement("tr");
tr.className = "b08-earthwork__total";
tr.dataset.b08EarthworkTotal = "";
const label = cell("th", "합계");
label.className = "b08-summary__station";
tr.append(label, ...values.map((v) => cell("td", v)));
return tr;
}
function renderSheet(host: HTMLElement, table: EarthworkTable, interval: number): void {
if (!table.rows.length) {
host.replaceChildren(note("측점 단면적이 아직 없습니다 — 횡단 설계를 먼저 마치세요."));
return;
}
const c = (kind: string): string => num(table.conversion_factors[kind]?.compacted);
const info = note(
`평균단면적법 · 노선 ${table.route_id}${table.confirmed ? "(확정)" : "(확정 전 최신 노선)"} · ` +
`측점 ${table.station_count} · 다짐 환산계수 토사 ${c("soil")} · 리핑암 ${c("ripping_rock")} · ` +
`발파암 ${c("blasting_rock")} · 보정량계 = 절토 보정량 · 면 거리 = B07 횡단도 수량 · ` +
`단위 m · ㎡ · ㎥ · * = 사유 있음(마우스를 올림)`,
);
const tbody = document.createElement("tbody");
tbody.append(...table.rows.map((row, i) => bodyRow(row, i === 0, interval)));
const tfoot = document.createElement("tfoot");
tfoot.append(totalRow(table));
const card = tableCard([info], "b08-earthwork__table", [head(), tbody, tfoot]);
card.querySelector("table")!.dataset.b08Earthwork = "table";
host.replaceChildren(card);
}
const SHEETS = [
{ id: "summary", label: "토공집계표" },
{ id: "sheet", label: "토적표" },
{ id: "haul", label: "평균운반거리" },
{ id: "free", label: "종방향 무대운반거리" },
] as const;
type SheetId = (typeof SHEETS)[number]["id"];
export async function renderEarthwork(host: HTMLElement, projectId: string): Promise<void> {
host.replaceChildren(note("토공 수량을 만드는 중…"));
let table: EarthworkTable;
let interval = 20;
try {
const [response, gap] = await Promise.all([
fetch(`${API_BASE_URL}/projects/${projectId}/quantity/earthwork`, { credentials: "include" }),
stationInterval(projectId).catch(() => 20),
]);
const body = (await response.json().catch(() => null)) as
(EarthworkTable & { detail?: unknown }) | null;
if (!response.ok || !body) {
const detail = body?.detail;
throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`);
}
table = body;
interval = gap;
} catch (error) {
host.replaceChildren(note(`토공 수량을 불러오지 못했습니다 — ${String(error)}`));
return;
}
const tabs = document.createElement("div");
tabs.className = "b08-quantity__tabs b08-earthwork__tabs";
tabs.setAttribute("role", "tablist");
const view = document.createElement("div");
const select = (picked: SheetId): void => {
tabs.replaceChildren(
...SHEETS.map(({ id, label }) => {
const button = createButton({
label,
variant: id === picked ? "filled" : "ghost",
size: "sm",
onClick: () => id !== picked && select(id),
});
button.setAttribute("role", "tab");
button.setAttribute("aria-selected", String(id === picked));
button.dataset.b08EarthworkSheet = id;
return button;
}),
);
if (picked === "sheet") renderSheet(view, table, interval);
else if (picked === "summary") renderEarthworkSummary(view, table);
else if (picked === "haul") renderHaulDistance(view, table);
else renderFreeHaul(view, table);
};
host.replaceChildren(tabs, view);
select("summary");
}