Files
Aislo/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts
T
eomsangdonandClaude Opus 5 475ad7875e feat(b08·b05·b09): 10-A 우리가 정한 SW 규칙을 화면에 드러냄 — 훑기 21건 중 안 보이거나 반만 보이던 13건 채움 + 9-21 제근 굴착기 크기 칸
- 문구 못박음 셋: ① 옹벽 높이 칸 밑 「기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음」(등록부 default_basis 를 모든 칸 밑 회색 한 줄로) · ② 일반관리비 「임도가 어느 쪽인지 규정이 없어 (주)공사 기본 · 칸에서 바꿀 수 있음」 · ③ 모르타르 배합 「산림품셈에 배합 절이 없어 건설품셈 [건축] 9-1-1 을 씀(교차 참조)」
- B08: 막자갈 비고 「칸을 새로 두는 것은 B05 등록부 몫」 · 일위대가 머리 5단 한도 · 야면석 계수 칸 밑 「돌 종류는 그대로」 · 식 저장 전 상태 줄 「제원을 바꿔도 따라감」 · 양식 장 머리 「반올림은 m당 값에 걸고 수량 = m당 × 연장」
- 산출 조건 제근 굴착기 크기 칸(임목축적 등급 밑 · 「안 정함」 · 회색 제안 0.7㎥ + 근거 + [제안값 넣기] 누른 때만) — 936be972 소림 + 0.7 → 뿌리뽑기 55원/㎡ × 17,873.80㎡ = 983,057원(되돌림)
- 라이브러리 이름표: [내 라이브러리에 저장]·[발행] 창이 장 이름(종류 + 제원 요약)을 채워 묻고 고친 이름으로 저장 · 취소면 안 씀 · 비우면 양식 이름
- B05 계곡 통과 시설 폼 머리 「[저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸은 그대로 둠」 · B09 폐기물처리비 자리 칸 밑 「법정경비 밑수에는 안 넣음」 · 내역서 「금액을 못 세운 줄」 첫 줄 「사유는 아래 단계가 낸 것을 그대로 옮김」
- 남은 하나(표 형태 66표 까닭 form_basis 화면)는 자원 축 파일을 거쳐야 해 브레인 물음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 19:44:18 +09:00

543 lines
23 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_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<string, number>;
areas: Record<string, number>;
}
export interface SlopeTable {
rows: SlopeRow[];
totals: Record<string, number>;
ratios: Record<string, number>;
unclosed_stations: number[];
}
/** 산출 조건 — `project_settings.json` 의 `quantity` 구획. */
export interface QuantitySettings {
/** 고른 토량환산계수 — `{갈래: {compacted, reason?}}`. 안 고르면 키가 없다. */
conversion_factors_override?: Record<string, { compacted?: number; reason?: string }> | null;
rock_class_set?: string;
rock_classes?: string[];
rock_ratios_pct?: Record<string, number>;
application_ratios_pct?: Record<string, number>;
/** 갈래별 시공법 — `"ripping"`·`"blasting"`. 안 정한 갈래는 아예 없다. */
rock_methods?: Record<string, string>;
/** 자재별 관급/사급 — 표 안에서 줄마다 고른 값. */
material_supply?: Record<string, { supply: string; install_by: string | null }>;
/** 자재별 할증률(%) — 표값 이내에서 고른 값만. `null`·없음이면 표값. */
material_surcharge?: Record<string, number | null>;
/** 콘크리트 타설 방식. `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<string, number | null>;
/** 임목축적 등급 — `"소림"`·`"중림"`·`"밀림"`(품셈 9-21 [주]①). 본수가 아니라 축적이다. */
stand_volume_class?: string | null;
/** 제근 굴착기 크기 — `"0.2"`·`"0.7"`(서버 선택지 안) · 비면 「안 정함」. */
root_removal_excavator_m3?: 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<string, number | null>;
/** 임목파쇄 — 기본 꺼짐(확정 5차 5번). 켜야 줄이 선다. */
wood_chipping_enabled?: boolean | null;
/** 파쇄 부피(㎥) — 켜도 이 값이 없으면 줄만 서고 사유가 남는다. */
wood_chipping_volume_m3?: number | null;
/** 노체다짐 — 기본 꺼짐(9-16-2 [주]⑤ 조건부). 켜면 토공집계에 별도 줄. */
subgrade_compaction_enabled?: boolean | null;
/** 임목폐기물 조사값 넷 — 비면 톤이 안 선다. */
tree_waste?: Record<string, number | null>;
/** 처리단가(원/톤) — 수동 · 미확정. */
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<string, number>;
conversion_factors?: Record<string, { compacted: number }>;
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<string, ConversionFactorChoice>;
/** 면고르기 갈래 선택지 — 품셈 9-19-1 원문 표 두 벌(서버 한 곳). 화면은 그대로 보임. */
face_dressing_choices?: { cut: string[]; fill: string[] };
/** 제근 굴착기 크기 선택지·제안(회색 · [제안값 넣기]) — 서버 한 곳. */
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<EarthworkRow[K]> 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<string, number>, 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<HTMLElement>(
`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;
}