/* ============================================================================= * B08_Quantity_UI_EarthworkGrid.ts * 토적표 그리드 — 실무 토적표(3단 머리글)를 그대로 그린다 (PLAN 8-4b). * * 왜 실무 서식 그대로인가 * 이 화면의 첫 사용자는 「프로그램이 맞나」를 확인하려는 설계자다. 보기 좋게 재배치하면 * 실무 산출서와 눈으로 대조를 못 한다. 열 순서·머리글 문구를 실무 시트에 맞춘다. * * ⚠ 소수 자리는 표기 규칙일 뿐이다 (PLAN 8-16) * 서버는 전정밀 값을 준다. 자르는 것은 여기(화면)뿐이다. 자리수는 **산림사업 표준품셈**을 * 따른다 — 단면적 ㎡ 1자리 · 입적·보정량·보정량계·유용토·차인·누가 ㎥ 2자리 · * 체적 합계 정수 · 거리 정수. 실무 시트 관측값(단면적 2자리 등)이 아니라 품셈이 기준이다. * ⚠ 매 단계 반올림은 오차가 쌓이므로 하지 않는다 — 자르는 곳은 이 표 한 곳뿐이다. * 원가 쪽(줄마다 원 단위 절사)과 규칙이 반대이므로 그 코드를 여기로 옮기지 말 것. * ========================================================================== */ import { attachProvenance, markProvenanceCell, type ProvenancePayload, type ProvenanceSheet, } from "@ui/ui_template_provenance"; /** 서버가 주는 토적표 한 줄. 이름은 엔진(`B08_Quantity_Engine_EarthworkTable.py`)과 같다. */ export 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; 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; /** 측구를 무슨 근거로 갈랐나 — B06 사유 그대로. `cut_area_ratio_fallback` 이면 안분이다. */ ditch_split_basis?: string; /** 줄에 남은 사유(폴백 안분 등). 표 칸이 아니라 주기로 보일 값이다. */ notes?: string[]; } /** 사면 4계열 — 계열별 (거리, 면적). 키는 `면고르기_성토면` 식으로 엔진과 같다. */ export interface SlopeRow { chainage_m: number; distance_m: number; berm_width_m: number; unclosed: boolean; lengths: Record; areas: Record; } export interface SlopeTable { rows: SlopeRow[]; totals: Record; ratios: Record; unclosed_stations: number[]; } /** 산출 조건 — `project_settings.json` 의 `quantity` 구획. */ export interface QuantitySettings { /** 고른 토량환산계수 — `{갈래: {compacted, reason?}}`. 안 고르면 키가 없다. */ conversion_factors_override?: Record | null; rock_class_set?: string; rock_classes?: string[]; rock_ratios_pct?: Record; application_ratios_pct?: Record; /** 갈래별 시공법 — `"ripping"`·`"blasting"`. 안 정한 갈래는 아예 없다. */ rock_methods?: Record; /** 자재별 관급/사급 — 표 안에서 줄마다 고른 값. */ material_supply?: Record; /** 자재별 할증률(%) — 표값 이내에서 고른 값만. `null`·없음이면 표값. */ material_surcharge?: Record; /** 콘크리트 타설 방식. `null`·없음이면 **아직 안 정한 것**이고 화면이 기본값 안내를 띄운다. */ concrete_placing_method?: string | null; /** 표토 두께(m). `null`·없음이면 **안 정한 것**이라 표토제거 줄이 「근거 없음」으로 선다. */ topsoil_thickness_m?: number | null; /** 층따기 길이(m) — 면적 × 이 값 = ㎥ (확정 2차 ①). 비면 층따기 줄이 막힌다. */ bench_cut_depth_m?: number | null; /** 기초잡석 두께(m) — 확정 3차 ② 0.2. 폭은 버림 폭과 같다(KCS 34 50 05). */ rubble_base_thickness_m?: number | null; /** 사토장까지 거리(m) — 현장값. 비면 사토 운반 줄이 막힌다. */ spoil_site_distance_m?: number | null; /** 구조물터파기 용수 — `"육상"`·`"용수"`. ⚠ 「육상」은 통상값이지 사용자 확정이 아니다. */ structure_trench_water?: string | null; /** 표토 운반거리(m) — 별표2 가 요구하는 운반·적치의 밑수. 비면 그 줄이 막힌다. */ topsoil_haul_distance_m?: number | null; /** 도쟈 한계거리(m) — `null`·없음이면 기본값(서버 정본 60 m)이 선다. */ dozer_haul_limit_m?: number | null; /** 부대시설 개소 — `{항목키: 개소}`. ⚠ 산식으로 만들지 않는다(확정 13). */ ancillary_counts?: Record; /** 임목축적 등급 — `"소림"`·`"중림"`·`"밀림"`(품셈 9-21 [주]①). 본수가 아니라 축적이다. */ stand_volume_class?: string | null; /** 제근 굴착기 크기 — `"0.2"`·`"0.7"`(서버 선택지 안) · 비면 「안 정함」. */ root_removal_excavator_m3?: string | null; /** 초류종자살포 비탈면 토질 — `"일반"`·`"마사토"`(5-24 잎) · 비면 「안 정함」. */ seed_spray_ground?: string | null; /** 면고르기 갈래(서버 선택지 안) · 면적 덮어쓰기(㎡, 없음 = 파종 면적). */ face_dressing_cut_class?: string | null; face_dressing_fill_class?: string | null; face_dressing_fill_area_m2?: number | null; face_dressing_cut_area_m2?: number | null; /** 표토제거 대상 — 없음이면 기본(노면 + 절토) · `"road_only"` 는 노면만. */ topsoil_target?: string | null; /** 규준틀 개소당 재료 — `{자재명: 수량}`. 비우면 제안값(실무 관측)이 선다. */ frame_material?: Record; /** 임목파쇄 — 기본 꺼짐(확정 5차 5번). 켜야 줄이 선다. */ wood_chipping_enabled?: boolean | null; /** 파쇄 부피(㎥) — 켜도 이 값이 없으면 줄만 서고 사유가 남는다. */ wood_chipping_volume_m3?: number | null; /** 노체다짐 — 기본 꺼짐(9-16-2 [주]⑤ 조건부). 켜면 토공집계에 별도 줄. */ subgrade_compaction_enabled?: boolean | null; /** 임목폐기물 조사값 넷 — 비면 톤이 안 선다. */ tree_waste?: Record; /** 처리단가(원/톤) — 수동 · 미확정. */ tree_waste_unit_price_krw_per_ton?: number | null; /** 폐기물 분리발주 — 기본 아님. */ waste_separate_order?: boolean | null; /** 뿌리 산정법 — 없음이면 분배비 15/85 · `"root_ball"` 은 뿌리분 체적 × 1,300. */ tree_waste_root_method?: string | null; } /** 갈래 하나의 「무엇을 골랐나」. 서버 `earthwork_conversion_choices` 와 짝이다. */ export interface ConversionFactorChoice { compacted: number; default: number; /** 기본값과 다른 값을 골랐나. */ chosen: boolean; /** 품셈 범위 안인가. 밖이어도 **막지 않고** 사유를 받는다. */ in_range: boolean; range: [number, number] | null; reason: string | null; } /** 품셈 암종별 체적변화율 범위 — **화면 안내용**이고 계산에 안 쓴다. */ export interface PumsemRange { name: string; min: number; max: number; } export interface EarthworkTable { method: string; station_count: number; route_id?: number; rows: EarthworkRow[]; totals: Record; conversion_factors?: Record; slope?: SlopeTable; /** 토공집계표·운반표는 같은 응답에 실려 온다 — 나눠 부르지 않는다. */ summary?: import("./B08_Quantity_UI_SummaryGrid").SummaryTable; haul?: import("./B08_Quantity_UI_SummaryGrid").HaulTable; /** 운반계획은 [저장]·[확정]에서 정본에 남는 값 — 아직 없으면 false. */ haul_available?: boolean; settings?: QuantitySettings; /** 갈래별 토량환산계수 선택 상태 — 산출 조건 패널이 그린다. */ conversion_factor_choices?: Record; /** 면고르기 갈래 선택지 — 품셈 9-19-1 원문 표 두 벌(서버 한 곳). 화면은 그대로 보임. */ face_dressing_choices?: { cut: string[]; fill: string[] }; /** 초류종자살포 비탈면 토질 선택지(5-24 잎 둘) — 서버 한 곳 · 제안값 없음. */ seed_spray_choices?: { choices: string[] }; /** 혼합석 부설 — 서버가 셈한 면적·부피·제안값(2026-09-15). 화면은 보이기만. */ gravel?: import("./B08_Quantity_UI_Side_Gravel").GravelSummary; /** 제근 굴착기 크기 선택지·제안(회색 · [제안값 넣기]) — 서버 한 곳. */ root_removal_excavator_choices?: { choices: string[]; suggested?: { value: string; basis: string }; }; /** 품셈 암종별 범위(안내용). 정의처가 서버라 내려받아 보인다. */ conversion_factor_pumsem_ranges?: PumsemRange[]; /** 도쟈 한계거리 — 지금 값·기본값·근거(서버가 정본). 종무대 20 m 는 규정이라 값만 보인다. */ haul_limit_choice?: { value: number; default: number; chosen: boolean; basis: string; free_haul_m: number; free_haul_basis: string; }; /** 근거 사전 — ⚠ **개발환경에서만** 실려 온다. 운영에서는 칸 자체가 없다. */ provenance?: ProvenancePayload; } /** 표 칸에 들어갈 수 있는 열 — 숫자 칸만 고른다(사유·주기는 표 밖이다). */ type NumericColumnKey = { [K in keyof EarthworkRow]-?: NonNullable extends number ? K : never; }[keyof EarthworkRow]; /** 열 하나. `digits` 는 **표기 자리**이며 값 자체는 자르지 않는다. */ interface Column { key: NumericColumnKey; digits: number; /** 합계행에 낼지 — 단면적은 합이 뜻이 없어 비운다(실무 시트도 비어 있다). */ sum?: boolean; } /** 실무 토적표 3단 머리글. 대분류 → 중분류 → 소분류 순서가 곧 열 순서다. */ const GROUPS: { label: string; sub: { label: string; cols: Column[] }[] }[] = [ { label: "", sub: [{ label: "측 점", cols: [{ key: "chainage_m", digits: 0 }] }] }, { label: "", sub: [{ label: "거 리", cols: [{ key: "distance_m", digits: 0, sum: true }] }] }, { label: "절 토", sub: [ { label: "토 사", cols: [ { key: "cut_soil_area_m2", digits: 1 }, { key: "cut_soil_volume_m3", digits: 2, sum: true }, { key: "cut_soil_adjusted_m3", digits: 2, sum: true }, ], }, { label: "암 석", cols: [ { key: "cut_rock_area_m2", digits: 1 }, { key: "cut_rock_volume_m3", digits: 2, sum: true }, { key: "cut_rock_adjusted_m3", digits: 2, sum: true }, ], }, ], }, { label: "측 구 터 파 기", sub: [ { label: "토 사", cols: [ { key: "ditch_soil_area_m2", digits: 1 }, { key: "ditch_soil_volume_m3", digits: 2, sum: true }, { key: "ditch_soil_adjusted_m3", digits: 2, sum: true }, ], }, { label: "암 석", cols: [ { key: "ditch_rock_area_m2", digits: 1 }, { key: "ditch_rock_volume_m3", digits: 2, sum: true }, { key: "ditch_rock_adjusted_m3", digits: 2, sum: true }, ], }, ], }, { label: "", sub: [{ label: "보정량계", cols: [{ key: "adjusted_total_m3", digits: 2, sum: true }] }], }, { label: "성 토", sub: [ { label: "", cols: [ { key: "fill_area_m2", digits: 1 }, { key: "fill_volume_m3", digits: 2, sum: true }, ], }, ], }, { label: "", sub: [{ label: "유 용 토", cols: [{ key: "diverted_m3", digits: 2, sum: true }] }] }, { label: "", sub: [{ label: "차인토량", cols: [{ key: "balance_m3", digits: 2, sum: true }] }] }, { label: "", sub: [{ label: "누가토량", cols: [{ key: "cumulative_m3", digits: 2 }] }] }, ]; /** 사면 4계열 — 실무 토적표 오른쪽 절반(V~AI). 계열마다 (거리, 면적) 쌍이다. * 키는 엔진과 같은 이름을 쓴다 — 이름이 어긋나면 값이 조용히 빈다. */ const SLOPE_GROUPS: { label: string; faces: { key: string; label: string }[] }[] = [ { label: "층 따 기", faces: [{ key: "bench_cut_fill", label: "성 토 면" }] }, { label: "면고르기", faces: [ { key: "face_dressing_fill", label: "성 토 면" }, { key: "face_dressing_cut", label: "절 토 면" }, ], }, { label: "법 면 보 호 공", faces: [ { key: "slope_protection_fill", label: "종자파종(성토)" }, { key: "slope_protection_cut", label: "종자파종(절토)" }, ], }, { label: "지 장 목 제 거", faces: [ { key: "tree_removal_fill", label: "성 토 면" }, { key: "tree_removal_cut", label: "절 토 면" }, ], }, ]; /** 사면 계열의 소분류 머리글 — 거리(사면길이)와 면적 두 칸. */ const SLOPE_LABELS = ["거 리", "면 적"]; /** 체적 합계 자리수 — 품셈이 정수로 정해 둔다. 거리 합계(m)도 같은 정수다. */ const SUM_DIGITS = 0; /** 소분류가 「단면적·입적·보정량」 삼중조일 때 붙는 3단 머리글 문구. */ const TRIPLE_LABELS = ["단면적", "입 적", "보정량"]; const PAIR_LABELS = ["단면적", "입 적"]; const flatColumns = (): Column[] => GROUPS.flatMap((g) => g.sub.flatMap((s) => s.cols)); /** 측점 표기 — `20` → `NO.1`, `25` → `NO.1+5`. 실무 토적표가 이 모양이다. * 구조물 원단위 표도 같은 표기를 쓰므로 **한 벌로 두고 내보낸다**(2026-09-09) — * 두 벌이면 같은 측점이 화면 두 곳에서 다르게 적힌다. */ export function stationLabel(chainage: number, interval = 20): string { const no = Math.floor(chainage / interval); const plus = chainage - no * interval; const rounded = Math.round(plus * 100) / 100; return rounded === 0 ? `NO.${no}` : `NO.${no}+${rounded}`; } function cell(value: number | undefined, digits: number): string { if (value === undefined || value === null || Number.isNaN(value)) return ""; if (value === 0) return ""; return value.toLocaleString("ko-KR", { minimumFractionDigits: digits, maximumFractionDigits: digits, }); } function buildHead(): HTMLTableSectionElement { const head = document.createElement("thead"); const r1 = document.createElement("tr"); const r2 = document.createElement("tr"); const r3 = document.createElement("tr"); for (const group of GROUPS) { const span = group.sub.reduce((n, s) => n + s.cols.length, 0); if (group.label) { const th = document.createElement("th"); th.colSpan = span; th.textContent = group.label; r1.append(th); for (const sub of group.sub) { const th2 = document.createElement("th"); th2.colSpan = sub.cols.length; th2.textContent = sub.label; r2.append(th2); const labels = sub.cols.length === 3 ? TRIPLE_LABELS : PAIR_LABELS; sub.cols.forEach((_, index) => { const th3 = document.createElement("th"); th3.textContent = labels[index] ?? ""; r3.append(th3); }); } continue; } // 대분류가 없는 열(측점·거리·보정량계·유용토·…)은 세 줄을 하나로 합친다. for (const sub of group.sub) { const th = document.createElement("th"); th.colSpan = sub.cols.length; th.rowSpan = 3; th.textContent = sub.label; r1.append(th); } } // 사면 4계열 — 대분류 / 면(성토·절토) / (거리·면적) 3단으로 같은 모양을 이어 붙인다. for (const group of SLOPE_GROUPS) { const th = document.createElement("th"); th.colSpan = group.faces.length * 2; th.textContent = group.label; r1.append(th); for (const face of group.faces) { const th2 = document.createElement("th"); th2.colSpan = 2; th2.textContent = face.label; r2.append(th2); for (const label of SLOPE_LABELS) { const th3 = document.createElement("th"); th3.textContent = label; r3.append(th3); } } } head.append(r1, r2, r3); return head; } function buildBody( rows: EarthworkRow[], slope?: SlopeTable, sheet?: ProvenanceSheet, ): HTMLTableSectionElement { const body = document.createElement("tbody"); const columns = flatColumns(); const slopeByChainage = new Map((slope?.rows ?? []).map((row) => [row.chainage_m, row])); for (const row of rows) { const tr = document.createElement("tr"); columns.forEach((column, index) => { const td = document.createElement("td"); td.textContent = index === 0 ? stationLabel(row.chainage_m) : cell(row[column.key], column.digits); if (index === 0) td.className = "b08-grid__station"; // 근거 호버·등급색은 **사전이 왔을 때만** 붙는다(개발환경). const columnProvenance = sheet?.columns[column.key]; if (columnProvenance) markProvenanceCell(td, column.key, columnProvenance.tier); tr.append(td); }); // 줄마다 갈리는 사유(측구 안분 폴백 등)는 열 사전이 못 든다 — 줄에 실어 카드가 덧붙게 한다. if (row.notes?.length) tr.dataset.provNotes = row.notes.join("\n"); const slopeRow = slopeByChainage.get(row.chainage_m); // 사면이 원지반을 못 만난 측점은 값이 잘려 있다 — 줄에 표시를 남긴다(PLAN 8-4b). if (slopeRow?.unclosed) tr.classList.add("is-unclosed"); for (const group of SLOPE_GROUPS) { for (const face of group.faces) { for (const source of [slopeRow?.lengths, slopeRow?.areas]) { const td = document.createElement("td"); td.textContent = cell(source?.[face.key], 1); tr.append(td); } } } body.append(tr); } return body; } function buildFoot(totals: Record, slope?: SlopeTable): HTMLTableSectionElement { const foot = document.createElement("tfoot"); const tr = document.createElement("tr"); flatColumns().forEach((column, index) => { const td = document.createElement("td"); if (index === 0) td.textContent = "계"; // 합계는 **정수**다(품셈 자리수 규칙). 열마다 다른 자리수를 그대로 쓰지 않는다 — // 합계행에 남는 소수는 실무 산출서와 모양이 어긋나는 자리다. 값은 안 자른다. else if (column.sum) td.textContent = cell(totals[column.key], SUM_DIGITS); tr.append(td); }); // 사면은 면적만 합한다 — 거리(사면길이)는 합이 뜻이 없다. for (const group of SLOPE_GROUPS) { for (const face of group.faces) { tr.append(document.createElement("td")); const td = document.createElement("td"); td.textContent = cell(slope?.totals?.[face.key], 1); tr.append(td); } } foot.append(tr); return foot; } /** 잘린 측점 안내 — 한 덩어리로 묶고, 목록은 접어 둔다. * * 왜 붉은 오류가 아닌가 * 실측 발생률이 21~26 %(랩탑 route 169 는 22/105, 이 노선은 17/65)라 **늘 뜨는 안내**다. * 매번 요란하면 곧 무시당한다. 그래서 **주의 표시 + 접히는 목록**으로 둔다. * * 왜 한 덩어리인가 * 절·성토 면적 · 사면적 · 사면길이가 **전부 같은 사유로** 잘린다. 항목마다 따로 띄우면 * 사용자가 세 번 읽게 된다. * * 왜 안 넓히나 (B06 담당 확인, 2026-09-07) * 미교차의 절반 이상이 계곡·절벽처럼 **지형이 설계 사면에서 멀어지는 자리**라 반폭을 * 늘려도 영원히 안 닫힌다. 닫히는 쪽도 중앙값 +3m 인데 꼬리가 +292m 이라 전역 확대는 * 값이 안 나온다. 그래서 경고로 대체한다(2026-09-03 사용자 확정). */ function buildUnclosedNotice(slope: SlopeTable, table: HTMLTableElement): HTMLElement | null { const stations = slope.unclosed_stations ?? []; if (!stations.length) return null; const box = document.createElement("details"); box.className = "b08-grid__warning"; const summary = document.createElement("summary"); summary.className = "b08-grid__warning-summary"; summary.textContent = `주의 — ${stations.length}개 측점에서 사면이 원지반을 만나지 못했습니다. ` + "그 측점의 절·성토 면적 · 사면길이 · 사면적이 함께 잘려 있어 실제보다 작습니다."; box.append(summary); const list = document.createElement("div"); list.className = "b08-grid__warning-list"; for (const chainage of stations) { const link = document.createElement("button"); link.type = "button"; link.className = "b08-grid__warning-station"; link.textContent = stationLabel(chainage); link.addEventListener("click", () => { const row = table.querySelector( `tbody tr:nth-child(${slopeRowIndex(slope, chainage) + 1})`, ); row?.scrollIntoView({ block: "center", behavior: "smooth" }); row?.classList.add("is-highlighted"); window.setTimeout(() => row?.classList.remove("is-highlighted"), 1600); }); list.append(link); } box.append(list); return box; } function slopeRowIndex(slope: SlopeTable, chainage: number): number { return slope.rows.findIndex((row) => row.chainage_m === chainage); } /** 토적표 하나를 그린다. 넓은 표라 스스로 가로 스크롤한다. */ export function renderEarthworkGrid(table: EarthworkTable): HTMLElement { const wrap = document.createElement("div"); wrap.className = "b08-grid"; const caption = document.createElement("p"); caption.className = "b08-grid__caption"; caption.textContent = `측점 ${table.station_count}곳 · 평균단면적법`; wrap.append(caption); const scroller = document.createElement("div"); scroller.className = "b08-grid__scroll"; const element = document.createElement("table"); element.className = "b08-grid__table"; const sheet = table.provenance?.sheets?.earthwork; element.append( buildHead(), buildBody(table.rows, table.slope, sheet), buildFoot(table.totals, table.slope), ); // 사전이 없으면 아무 일도 안 한다 — 빈 카드를 띄우면 「설명이 있다」는 거짓만 남는다. // 줄 사유는 **그 사유가 닿는 열에만** 붙인다. 줄에 달렸다고 십몇 칸에 다 띄우면 // 「절토 보정량」 카드에 「측구 가름값…」 이 떠서 읽는 사람을 속인다(2026-09-12 실측). // ⚠ 지금 줄 사유는 **측구 안분 폴백 하나뿐**이라 여기서 열 이름으로 가른다. // 사유가 늘면 엔진이 「어느 열에 닿는 사유인가」를 같이 내는 쪽이 맞다. attachProvenance(element, sheet, (cell, columnKey) => { if (!columnKey.startsWith("ditch_")) return []; return (cell.closest("tr")?.dataset.provNotes ?? "").split("\n").filter(Boolean); }); if (table.slope) { const notice = buildUnclosedNotice(table.slope, element); if (notice) wrap.append(notice); } scroller.append(element); wrap.append(scroller); return wrap; }