diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index ff91d278..509ef116 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -24,6 +24,8 @@ from B06_Section.B06_Section_Repository import ( get_workflow_route_context, ) from B08_Quantity.B08_Quantity_Engine_EarthworkTable import StationArea, build_table +from B08_Quantity.B08_Quantity_Engine_SlopeArea import build_table as build_slope_table +from B08_Quantity.B08_Quantity_Engine_SlopeLength import station_slopes from config.config_db import run_with_connection logger = logging.getLogger(__name__) @@ -38,7 +40,11 @@ def _stations(designs: list[dict[str, Any]]) -> list[StationArea]: @router.get("/{project_id}/quantity/{route_id}/earthwork-table") async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse: - """토적표 — 측점별 단면적을 평균단면적법으로 체적화한 표.""" + """토적표 — 토공(체적)과 사면 4계열(면적)을 **한 응답**으로 낸다. + + 실무 토적표가 한 장이라 화면도 한 장이다. 나눠 부르면 두 번 왕복하고, 같은 측점 목록을 + 두 벌로 들게 된다. + """ try: designs = await run_with_connection(get_cross_section_designs, route_id) except Exception: @@ -48,6 +54,8 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse: content={"status": "error", "message": "토적표를 만들지 못했습니다."}, ) table = build_table(_stations(designs)) + # 사면 계열은 저장된 설계선에서 유도한다 — 반영률은 기본 100 %(설계자 입력은 후속). + table["slope"] = build_slope_table(station_slopes(designs)) table["route_id"] = route_id return JSONResponse(content=table) diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts index 41e571fc..76aec807 100644 --- a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts @@ -36,12 +36,31 @@ export interface EarthworkRow { cumulative_m3: number; } +/** 사면 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[]; +} + export interface EarthworkTable { method: string; station_count: number; route_id?: number; rows: EarthworkRow[]; totals: Record; + conversion_factors?: Record; + slope?: SlopeTable; } /** 열 하나. `digits` 는 **표기 자리**이며 값 자체는 자르지 않는다. */ @@ -119,12 +138,46 @@ const GROUPS: { label: string; sub: { label: string; cols: Column[] }[] }[] = [ { label: "", sub: [{ label: "누가토량", cols: [{ key: "cumulative_m3", digits: 1 }] }] }, ]; +/** 사면 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 = ["거 리", "면 적"]; + /** 소분류가 「단면적·입적·보정량」 삼중조일 때 붙는 3단 머리글 문구. */ const TRIPLE_LABELS = ["단면적", "입 적", "보정량"]; const PAIR_LABELS = ["단면적", "입 적"]; const flatColumns = (): Column[] => GROUPS.flatMap((g) => g.sub.flatMap((s) => s.cols)); +/** 사면 열 개수 — 계열마다 (거리, 면적) 두 칸. */ +const slopeColumnCount = (): number => + SLOPE_GROUPS.reduce((n, group) => n + group.faces.length * 2, 0); + /** 측점 표기 — `20` → `NO.1`, `25` → `NO.1+5`. 실무 토적표가 이 모양이다. */ function stationLabel(chainage: number, interval = 20): string { const no = Math.floor(chainage / interval); @@ -178,13 +231,35 @@ function buildHead(): HTMLTableSectionElement { 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[]): HTMLTableSectionElement { +function buildBody(rows: EarthworkRow[], slope?: SlopeTable): 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) => { @@ -194,12 +269,25 @@ function buildBody(rows: EarthworkRow[]): HTMLTableSectionElement { if (index === 0) td.className = "b08-grid__station"; tr.append(td); }); + + 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): HTMLTableSectionElement { +function buildFoot(totals: Record, slope?: SlopeTable): HTMLTableSectionElement { const foot = document.createElement("tfoot"); const tr = document.createElement("tr"); flatColumns().forEach((column, index) => { @@ -208,10 +296,55 @@ function buildFoot(totals: Record): HTMLTableSectionElement { else if (column.sum) td.textContent = cell(totals[column.key], column.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; } +/** 잘린 측점 경고 — 무엇이 잘렸는지 적고, 측점을 눌러 그 줄로 가게 한다. */ +function buildUnclosedNotice(slope: SlopeTable, table: HTMLTableElement): HTMLElement | null { + const stations = slope.unclosed_stations ?? []; + if (!stations.length) return null; + + const box = document.createElement("div"); + box.className = "b08-grid__warning"; + const text = document.createElement("span"); + text.textContent = + `사면이 원지반을 만나지 못해 ${stations.length}개 측점에서 ` + + "사면길이·면적이 그 지점에서 잘렸습니다. 실제 값은 이보다 큽니다. " + + "같은 사유로 절·성토 면적도 잘려 있습니다."; + box.append(text); + + 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); + }); + box.append(link); + } + 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"); @@ -226,7 +359,16 @@ export function renderEarthworkGrid(table: EarthworkTable): HTMLElement { scroller.className = "b08-grid__scroll"; const element = document.createElement("table"); element.className = "b08-grid__table"; - element.append(buildHead(), buildBody(table.rows), buildFoot(table.totals)); + element.append( + buildHead(), + buildBody(table.rows, table.slope), + buildFoot(table.totals, table.slope), + ); + + if (table.slope) { + const notice = buildUnclosedNotice(table.slope, element); + if (notice) wrap.append(notice); + } scroller.append(element); wrap.append(scroller); return wrap; diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts index a3577ff0..ca3e4ee3 100644 --- a/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts @@ -63,6 +63,39 @@ const CSS = ` font-weight: var(--font-weight-medium, 600); } +/* 잘린 측점 경고 — 조용히 적게 내지 않고 눈에 보이게 한다(PLAN 8-4b). */ +.b08-grid__warning { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 6px; + padding: 6px 10px; + font-size: 12px; + color: var(--color-text-body); + background: var(--color-surface-raised); + border-left: 3px solid var(--color-danger); +} + +.b08-grid__warning-station { + font-size: 11px; + padding: 0 6px; + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text); + cursor: pointer; + font-variant-numeric: tabular-nums; +} + +/* 잘린 줄은 표에서도 알아보게 왼쪽에 표시를 남긴다. */ +.b08-grid__table tbody tr.is-unclosed .b08-grid__station { + border-left: 3px solid var(--color-danger); +} + +.b08-grid__table tbody tr.is-highlighted td { + background: var(--color-royal-amethyst, #d8ccff); + color: #1b2220; +} + .b08-quantity__tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--color-border); } .b08-quantity__tab {