From 6c90c26eeffb28e5278d6830e0b632f2d54f0cd5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 3 Aug 2026 19:06:46 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20=EA=B3=84=ED=9A=8D=20=EC=9C=A0?= =?UTF-8?q?=ED=86=A0=EA=B3=A1=EC=84=A0=20=EC=9D=B4=EA=B4=80=20+=20?= =?UTF-8?q?=EC=82=AC=ED=86=A0=EC=9E=A5=C2=B7=ED=86=A0=EC=B7=A8=EC=9E=A5=20?= =?UTF-8?q?4=EC=98=B5=EC=85=98=20=EC=9E=90=EB=8F=99=20=EC=84=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 절·성토 균형은 종단 시공계획고로 정해지고 사토장 위치도 계획고를 다시 끌어야 정리되므로, 계획용 유토곡선과 부지 선정을 B05로 옮겼다. B06은 실측 단면적으로 낸 정식 곡선과 확정·B08 인계를 맡는다. B05 계획 유토곡선 - _UI_Profile_MassHaul.ts: 종단면 패널 안 2차 하단 슬라이드. 펼치면 12행 도면 테이블 자리를 곡선이 대신 차지한다. 곡선 SVG를 종단 그래프와 같은 가로 스크롤러 안 형제로 넣고 MassHaulAxis에 LONG_PAD + originOffset을 넘겨 X축을 종단 chainageMapper와 일치시켰다. - computeLongitudinalMassHaul(): 횡단 설계가 없는 계획 단계용 개략 엔진. 표준횡단 노반폭을 전 구간 공통으로 물린다. 종단 기반 토량은 국내 기준상 노선계획 개산용 이므로(2026-08-03 조사) 표준단면까지 씌워 정밀화하지 않는다. 사토장·토취장 자동 선정 - B05_wf2_Route_Engine_Disposal.py + POST /route/disposal-sites: 노선 corridor DEM 격자를 훑어 후보 부지를 추리고 옵션별 점수로 정렬한다. 기준 4가지 — 비용(최단거리+하향 운반), 지형안정성(완경사·계곡 이격), 계곡부(계곡 축 매립), 임내 공간(라이다 수고 기반 공터). 기준값 정의처는 DISPOSAL_SITE_CRITERIA. - _UI_Profile_Disposal.ts: 범례 줄의 [토량 분배]와 [도형 위치 초기화] 사이에 구분기호와 함께 라디오 토글 4개. 부지 카드에 위치·용량·운반거리와 기준별 근거값, 측점·용량 사용자 정의 편집과 복원. 후보 부족으로 남은 토량은 경고로 알린다. - 확정 시 종단 정본의 disposal_sites에 심는다(기준 미선택이면 저장분 삭제). B06 연동 - common_util_mass_haul_sites.ts: 저장된 부지를 정식 곡선의 잔량 위치 기준으로 운반거리를 다시 재 표시하고 mass_haul.disposal_sites로 B08에 넘긴다. 미구현(계획서에 남김): 토취장 토질 판정(지반유형이 B06 산출물), 계곡부 암거 연장. Co-Authored-By: Claude Opus 5 (1M context) --- B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts | 66 +++ .../B05_wf2_Route_Engine_Disposal.py | 432 ++++++++++++++++++ B05_wf2_Route/B05_wf2_Route_Router.py | 77 +++- B05_wf2_Route/B05_wf2_Route_Router_Confirm.py | 23 + B05_wf2_Route/B05_wf2_Route_Schema.py | 45 ++ B05_wf2_Route/B05_wf2_Route_UI_Page.ts | 17 + .../B05_wf2_Route_UI_Profile_Disposal.ts | 368 +++++++++++++++ .../B05_wf2_Route_UI_Profile_MassHaul.ts | 206 +++++++++ .../B05_wf2_Route_UI_Profile_Panel.ts | 99 ++-- .../B05_wf2_Route_UI_Style_Disposal.css | 121 +++++ .../B05_wf2_Route_UI_Style_MassHaul.css | 74 +++ .../B06_wf3_ProfileCross_Api_Fetch.ts | 6 + .../B06_wf3_ProfileCross_UI_Page.ts | 6 +- .../B06_wf3_ProfileCross_UI_Section_View.ts | 4 + common_util/common_util_mass_haul.css | 14 + common_util/common_util_mass_haul.ts | 59 +++ common_util/common_util_mass_haul_sites.ts | 126 +++++ common_util/common_util_mass_haul_view.ts | 14 + config/config_system.py | 40 ++ 19 files changed, 1769 insertions(+), 28 deletions(-) create mode 100644 B05_wf2_Route/B05_wf2_Route_Engine_Disposal.py create mode 100644 B05_wf2_Route/B05_wf2_Route_UI_Profile_Disposal.ts create mode 100644 B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul.ts create mode 100644 B05_wf2_Route/B05_wf2_Route_UI_Style_Disposal.css create mode 100644 B05_wf2_Route/B05_wf2_Route_UI_Style_MassHaul.css create mode 100644 common_util/common_util_mass_haul_sites.ts diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index 427818da..7b71ae05 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -5,6 +5,7 @@ * 백엔드 계약 (B05_wf2_Route_Router.py): * POST /api/projects/{project_id}/route/solve → 경로 탐색 + DB 기록 * POST /api/projects/{project_id}/route/confirm → 최신 경로 확정 + * POST /api/projects/{project_id}/route/disposal-sites → 사토장·토취장 후보지 선정 * * 규칙: * - 모든 제어 상수는 config_frontend에서 참조 (하드코딩 금지). @@ -242,6 +243,8 @@ export interface RouteConfirmRequest { irregular_stations?: Array<{ chainage_m: number; structure: string }>; /** 측점 상단측(=측구 방향) 사용자 변경분 — 3D 램프 클릭으로 지정. */ uphill_overrides?: Array<{ chainage_m: number; side: "left" | "right" }>; + /** 사토장·토취장 선정 결과. null이면 종단 정본에 저장된 이전 선정을 지운다. */ + disposal_sites?: Record | null; } /** 프로젝트의 최신 경로를 확정한다. 비정규 측점이 있으면 그 횡단까지 생성한다. */ @@ -297,3 +300,66 @@ export function clearRouteLatestCache(projectId: string): void { /* 세션 접근 실패 시에는 다음 진입에서 DB를 읽게 되므로 그대로 둔다. */ } } + +/** 사토장·토취장 후보지 선정 기준. 프론트 토글 4개와 1:1로 대응한다. */ +export type DisposalOption = "cost" | "stability" | "valley" | "clearing"; + +/** 유토곡선이 낸 잉여(사토)/부족(토취) 구간 하나. */ +export interface DisposalZone { + from_m: number; + to_m: number; + volume_m3: number; +} + +/** 후보지 하나. 사용자 정의로 덮어쓸 수 있는 값은 `chainage_m`·`assigned_m3`다. */ +export interface DisposalSite { + id: string; + kind: "spoil" | "borrow"; + chainage_m: number; + offset_m: number; + side: "left" | "right"; + x: number; + y: number; + elevation_m: number; + capacity_m3: number; + assigned_m3: number; + haul_distance_m: number; + mean_slope: number; + max_slope: number; + /** 노면 대비 낙차(m). 양수면 부지가 노면보다 낮아 하향 운반이다. */ + drop_m: number; + valley_distance_m: number | null; + canopy_m: number | null; + score: number; +} + +export interface DisposalSitesResponse { + status: string; + project_id: string; + route_id: number; + option: DisposalOption; + spoil: DisposalSite[]; + borrow: DisposalSite[]; + route_length_m: number; + /** 후보 부지가 모자라 담지 못한 토량(㎥). 0이 아니면 화면이 경고로 알린다. */ + unassigned_spoil_m3: number; + unassigned_borrow_m3: number; +} + +/** 선정 기준 하나로 사토장·토취장 후보지를 계산한다(서버 저장 없음, 조회 전용). */ +export async function computeDisposalSites( + projectId: string, + routeId: number, + option: DisposalOption, + volumes: { + surplus_m3: number; + shortage_m3: number; + surplus_zones: DisposalZone[]; + shortage_zones: DisposalZone[]; + }, +): Promise { + return requestJson(`/projects/${projectId}/route/disposal-sites`, { + method: "POST", + body: JSON.stringify({ route_id: routeId, option, ...volumes }), + }); +} diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Disposal.py b/B05_wf2_Route/B05_wf2_Route_Engine_Disposal.py new file mode 100644 index 00000000..380db8c6 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Disposal.py @@ -0,0 +1,432 @@ +"""사토장·토취장 후보지 자동 선정 엔진 (B05 계획 유토곡선). + +유토곡선이 낸 잉여(사토)·부족(토취) 토량을 노선 주변 어디서 처리할지 고른다. +DEM 격자에서 후보 부지를 추리고 옵션별 점수로 정렬해 상위 몇 곳을 돌려준다. + +── 왜 노선 corridor 안에서만 찾는가 ───────────────────────────────── +사토·토취는 운반비가 곧 비용이라, 노선에서 멀어지면 실무적으로 선택지가 아니다. +탐색 범위를 `DISPOSAL_SITE_CRITERIA["corridor_half_width_m"]`로 묶어 계산량을 노선 +연장에 비례하게 유지한다(2026-08-03 사용자 확정). + +── 선정 기준 4가지 ───────────────────────────────────────────────── +실무 선정 기준(토목시공학 계열 정리, 2026-08-03 조사)을 격자 판정으로 옮겼다. + + cost 운반거리 최소 + 하향 운반 우대. 트럭이 흙을 싣고 오르막을 타지 않게 한다. + stability 산사태·붕괴 위험이 없는 평탄지·완경사지. 계곡부·집수 상부는 감점. + valley 계곡부 매립 — 배관(암거) 지점과 연계해 주변부를 쌓아 올리는 실무 방식. + clearing 임내 공간 — 라이다 식생 높이가 낮은 공터·벌채지를 우선. + +토취장은 파내는 쪽이라 사토장보다 경사 상한이 관대하다(`borrow_max_ground_slope`). +**토질 판정은 아직 없다** — 지반유형(토사/리핑암/발파암)은 B06 횡단 설계가 만드는 값이고 +B05 계획 단계에는 노선 위 측점의 지반유형조차 확정되지 않았다. 지금은 경사만 보고 거르며, +발파암 배제는 B06 확정 지반유형이 붙은 뒤에 걸어야 한다. + +기준값 정의처는 `config_system.DISPOSAL_SITE_CRITERIA` 한 곳뿐이다. +""" + +from __future__ import annotations + +import logging +import math +from pathlib import Path +from typing import Any + +import numpy as np + +from B05_wf2_Route.B05_wf2_Route_Engine_Skeleton import load_or_build_skeleton +from B05_wf2_Route.B05_wf2_Route_Engine_Solver import _load_or_build_cost_surface +from config.config_system import DISPOSAL_SITE_CRITERIA + +logger = logging.getLogger(__name__) + +_MODELS_SUBDIR = Path("B04_wf1_Surface") / "models" +_STRUCTURED_PATH = Path("B04_wf1_Surface") / "processed" / "structured.npz" + +#: 지원하는 선정 옵션. 프론트 토글 4개와 1:1로 대응한다. +DISPOSAL_OPTIONS = ("cost", "stability", "valley", "clearing") + + +class DisposalSiteError(ValueError): + """후보지 계산에 필요한 입력이 없거나 형식이 어긋날 때.""" + + +def _route_arrays(polyline: list[list[float]]) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """노선 폴리라인을 (xy, z, 누가거리) 배열로 편다.""" + if len(polyline) < 2: + raise DisposalSiteError("노선 좌표가 2점 미만이라 후보지를 찾을 수 없습니다.") + points = np.asarray(polyline, dtype=float) + xy = points[:, :2] + z = points[:, 2] if points.shape[1] > 2 else np.zeros(len(points)) + steps = np.linalg.norm(np.diff(xy, axis=0), axis=1) + chainage = np.concatenate([[0.0], np.cumsum(steps)]) + return xy, z, chainage + + +def _resample_route( + xy: np.ndarray, z: np.ndarray, chainage: np.ndarray, spacing_m: float +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """노선을 일정 간격으로 다시 찍고 각 점의 진행 방향 단위벡터를 함께 낸다.""" + total = float(chainage[-1]) + count = max(int(total // max(spacing_m, 1.0)) + 1, 2) + targets = np.linspace(0.0, total, count) + sx = np.interp(targets, chainage, xy[:, 0]) + sy = np.interp(targets, chainage, xy[:, 1]) + sz = np.interp(targets, chainage, z) + samples = np.column_stack([sx, sy]) + tangent = np.gradient(samples, axis=0) + norm = np.linalg.norm(tangent, axis=1, keepdims=True) + tangent = np.divide(tangent, np.where(norm > 1e-9, norm, 1.0)) + return samples, sz, targets, tangent + + +def _valley_points(project_root: Path, filter_key: str, method: str, smooth: bool) -> np.ndarray: + """지형 스켈레톤의 계곡 축 좌표를 한 배열로 모은다. 없으면 빈 배열.""" + try: + skeleton = load_or_build_skeleton(project_root, filter_key, method, smooth) + except (FileNotFoundError, OSError, ValueError) as exc: + logger.warning("사토장 선정: 지형 스켈레톤을 열지 못했습니다 — %s", exc) + return np.empty((0, 2)) + collected: list[list[float]] = [] + for key in ("main_valley", "minor_valley"): + for line in skeleton.get(key) or []: + for point in line.get("polyline") or []: + if len(point) >= 2: + collected.append([float(point[0]), float(point[1])]) + return np.asarray(collected, dtype=float) if collected else np.empty((0, 2)) + + +def _canopy_grid(project_root: Path) -> tuple[dict[tuple[int, int], float], float] | None: + """라이다 점군에서 격자별 식생 높이(지표 대비 최고점)를 만든다. + + 구조화 점군(`structured.npz`)의 분류값을 쓰되, 분류가 없는 자료도 있으므로 + **격자 안 최고점 − 최저점**을 수고로 본다. 지표가 격자 안에서 크게 기울면 + 그 경사분이 수고에 섞이지만, 공터 판정은 "낮은 쪽"만 골라내면 되므로 + 보수적(=공터를 덜 잡는) 방향이라 문제되지 않는다. + """ + path = project_root / _STRUCTURED_PATH + if not path.is_file(): + return None + try: + with np.load(path, allow_pickle=False, mmap_mode="r") as data: + xyz = np.asarray(data["xyz"], dtype=float) + except (OSError, ValueError, KeyError) as exc: + logger.warning("사토장 선정: 구조화 점군을 읽지 못했습니다 — %s", exc) + return None + if xyz.size == 0: + return None + cell = float(DISPOSAL_SITE_CRITERIA["clearing_grid_m"]) + keys = np.floor(xyz[:, :2] / cell).astype(np.int64) + lowest: dict[tuple[int, int], float] = {} + highest: dict[tuple[int, int], float] = {} + for (col, row), height in zip(map(tuple, keys), xyz[:, 2]): + key = (int(col), int(row)) + if key not in lowest or height < lowest[key]: + lowest[key] = float(height) + if key not in highest or height > highest[key]: + highest[key] = float(height) + return {key: highest[key] - lowest[key] for key in lowest}, cell + + +class _Terrain: + """DEM 격자 접근 묶음 — 표고·경사를 좌표로 바로 묻는다.""" + + def __init__(self, project_root: Path, filter_key: str, method: str, smooth: bool) -> None: + x, y, z, valid, dz_dx, dz_dy, grid_res = _load_or_build_cost_surface( + project_root, project_root / _MODELS_SUBDIR, filter_key, method, smooth + ) + self.x = np.asarray(x, dtype=float) + self.y = np.asarray(y, dtype=float) + self.z = np.asarray(z, dtype=float) + self.valid = np.asarray(valid, dtype=bool) + self.slope = np.hypot(np.asarray(dz_dx, dtype=float), np.asarray(dz_dy, dtype=float)) + self.grid_res = float(grid_res) + + def index(self, px: float, py: float) -> tuple[int, int] | None: + """좌표를 격자 (row, col)로 옮긴다. 격자 밖이거나 무효면 None.""" + if self.x.size < 2 or self.y.size < 2: + return None + col = int(round((px - self.x[0]) / (self.x[1] - self.x[0]))) + row = int(round((py - self.y[0]) / (self.y[1] - self.y[0]))) + if not (0 <= row < self.z.shape[0] and 0 <= col < self.z.shape[1]): + return None + return (row, col) if bool(self.valid[row, col]) else None + + def patch_slope( + self, row: int, col: int, radius_cells: int, slope_limit: float + ) -> tuple[float, float, int]: + """후보 주변 격자의 (평균경사, 최대경사, **쓸 수 있는 칸 수**). + + 마지막 값은 경사 상한을 넘지 않는 유효 칸만 센 것이다 — 부지 용량은 반경 안 + 전체가 아니라 실제로 흙을 올릴 수 있는 넓이라야 후보마다 값이 갈린다. + """ + r0 = max(row - radius_cells, 0) + r1 = min(row + radius_cells + 1, self.z.shape[0]) + c0 = max(col - radius_cells, 0) + c1 = min(col + radius_cells + 1, self.z.shape[1]) + window = self.slope[r0:r1, c0:c1] + mask = self.valid[r0:r1, c0:c1] + if not mask.any(): + return (math.inf, math.inf, 0) + values = window[mask] + usable = int((mask & (window <= slope_limit)).sum()) + return (float(values.mean()), float(values.max()), usable) + + +def _zone_center(zones: list[dict[str, Any]]) -> float | None: + """토량 가중 평균 위치(m). 잉여/부족이 어디에 몰려 있는지의 대표값.""" + total = sum(float(zone.get("volume_m3") or 0.0) for zone in zones) + if total <= 0: + return None + weighted = sum( + float(zone.get("volume_m3") or 0.0) + * 0.5 + * (float(zone.get("from_m") or 0.0) + float(zone.get("to_m") or 0.0)) + for zone in zones + ) + return weighted / total + + +def _score( + option: str, + kind: str, + *, + haul_distance_m: float, + route_length_m: float, + mean_slope: float, + drop_m: float, + valley_distance_m: float | None, + canopy_m: float | None, +) -> float: + """0~1 점수. 옵션마다 무엇을 크게 보는지만 다르고 계산 재료는 공유한다.""" + near = 1.0 - min(haul_distance_m / max(route_length_m, 1.0), 1.0) + flat = 1.0 - min(mean_slope / max(float(DISPOSAL_SITE_CRITERIA["max_ground_slope"]), 1e-6), 1.0) + # 사토장은 노면보다 낮아야 트럭이 오르막을 타지 않는다(토취장은 반대로 무관). + downhill = 1.0 if kind == "spoil" and drop_m > 0 else 0.5 if drop_m > -2.0 else 0.0 + buffer_m = float(DISPOSAL_SITE_CRITERIA["valley_buffer_m"]) + if valley_distance_m is None: + away_from_valley = 0.5 + in_valley = 0.0 + else: + away_from_valley = min(valley_distance_m / max(buffer_m, 1e-6), 1.0) + in_valley = 1.0 - away_from_valley + open_ground = ( + 0.5 + if canopy_m is None + else 1.0 + - min(canopy_m / max(float(DISPOSAL_SITE_CRITERIA["clearing_max_canopy_m"]), 1e-6), 1.0) + ) + + if option == "cost": + return 0.6 * near + 0.25 * downhill + 0.15 * flat + if option == "stability": + return 0.5 * flat + 0.3 * away_from_valley + 0.2 * near + if option == "valley": + return 0.5 * in_valley + 0.3 * near + 0.2 * flat + return 0.5 * open_ground + 0.3 * near + 0.2 * flat + + +def _build_candidates( + *, + option: str, + kind: str, + terrain: _Terrain, + samples: np.ndarray, + sample_z: np.ndarray, + chainages: np.ndarray, + tangent: np.ndarray, + valley_xy: np.ndarray, + canopy: tuple[dict[tuple[int, int], float], float] | None, + focus_m: float | None, + route_length_m: float, +) -> list[dict[str, Any]]: + """corridor를 훑어 후보 부지를 만든다. 옵션·용도별 판정을 여기서 건다.""" + criteria = DISPOSAL_SITE_CRITERIA + half_width = float(criteria["corridor_half_width_m"]) + offset_step = float(criteria["offset_spacing_m"]) + radius_cells = max(int(round(float(criteria["flat_radius_m"]) / terrain.grid_res)), 1) + slope_limit = float( + criteria["borrow_max_ground_slope"] if kind == "borrow" else criteria["max_ground_slope"] + ) + depth_m = float(criteria["borrow_depth_m"] if kind == "borrow" else criteria["fill_depth_m"]) + buffer_m = float(criteria["valley_buffer_m"]) + cell_area = terrain.grid_res**2 + + offsets = [ + sign * step + for step in np.arange(offset_step, half_width + 1e-6, offset_step) + for sign in (1.0, -1.0) + ] + candidates: list[dict[str, Any]] = [] + for index in range(len(samples)): + # 진행방향에 수직인 법선으로 좌우를 벌린다(좌 = +, 우 = −). + normal = np.array([-tangent[index, 1], tangent[index, 0]]) + for offset in offsets: + px, py = samples[index] + normal * offset + cell = terrain.index(px, py) + if cell is None: + continue + row, col = cell + mean_slope, max_slope, usable_cells = terrain.patch_slope( + row, col, radius_cells, slope_limit + ) + if usable_cells <= 0: + continue + if not math.isfinite(mean_slope) or mean_slope > slope_limit: + continue + valley_distance = ( + float(np.min(np.linalg.norm(valley_xy - np.array([px, py]), axis=1))) + if valley_xy.size + else None + ) + # 안정성 기준은 계곡부를 빼고, 계곡부 매립 기준은 계곡부만 남긴다. + if option == "stability" and valley_distance is not None and valley_distance < buffer_m: + continue + if option == "valley" and (valley_distance is None or valley_distance > buffer_m): + continue + canopy_m: float | None = None + if canopy is not None: + grid, cell_size = canopy + canopy_m = grid.get((int(px // cell_size), int(py // cell_size))) + if option == "clearing" and ( + canopy_m is None or canopy_m > float(criteria["clearing_max_canopy_m"]) + ): + continue + + elevation = float(terrain.z[row, col]) + drop_m = float(sample_z[index]) - elevation + chainage = float(chainages[index]) + haul_distance = ( + abs(chainage - focus_m) + abs(offset) if focus_m is not None else abs(offset) + ) + candidates.append( + { + "kind": kind, + "chainage_m": round(chainage, 2), + "offset_m": round(float(offset), 2), + "side": "left" if offset > 0 else "right", + "x": round(float(px), 3), + "y": round(float(py), 3), + "elevation_m": round(elevation, 3), + # 용량 = 경사 상한을 넘지 않는 격자 면적 × 허용 두께. + "capacity_m3": round(usable_cells * cell_area * depth_m, 1), + "haul_distance_m": round(haul_distance, 1), + "mean_slope": round(mean_slope, 4), + "max_slope": round(max_slope, 4), + "drop_m": round(drop_m, 2), + "valley_distance_m": ( + None if valley_distance is None else round(valley_distance, 1) + ), + "canopy_m": None if canopy_m is None else round(canopy_m, 2), + "score": round( + _score( + option, + kind, + haul_distance_m=haul_distance, + route_length_m=route_length_m, + mean_slope=mean_slope, + drop_m=drop_m, + valley_distance_m=valley_distance, + canopy_m=canopy_m, + ), + 4, + ), + } + ) + return candidates + + +def _pick(candidates: list[dict[str, Any]], demand_m3: float) -> list[dict[str, Any]]: + """점수 순으로 고르되 서로 겹치지 않게 띄우고, 필요한 토량이 찰 때까지만 담는다.""" + criteria = DISPOSAL_SITE_CRITERIA + spacing = float(criteria["flat_radius_m"]) * 2.0 + limit = int(criteria["max_candidates"]) + chosen: list[dict[str, Any]] = [] + remaining = max(demand_m3, 0.0) + for entry in sorted(candidates, key=lambda item: -item["score"]): + if len(chosen) >= limit: + break + if any( + abs(entry["chainage_m"] - picked["chainage_m"]) < spacing + and entry["side"] == picked["side"] + for picked in chosen + ): + continue + assigned = min(entry["capacity_m3"], remaining) if remaining > 0 else 0.0 + chosen.append( + {**entry, "id": f"{entry['kind']}-{len(chosen) + 1}", "assigned_m3": round(assigned, 1)} + ) + remaining -= assigned + if remaining <= 0: + break + return chosen + + +def compute_disposal_sites( + project_root: Path, + route_polyline: list[list[float]], + *, + option: str, + filter_key: str, + method: str, + smooth: bool, + surplus_m3: float, + shortage_m3: float, + surplus_zones: list[dict[str, Any]] | None = None, + shortage_zones: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """옵션 하나에 대한 사토장·토취장 후보지를 계산한다. + + 잉여가 없으면 사토장을, 부족이 없으면 토취장을 빈 목록으로 돌려준다 — + 필요 없는 부지를 억지로 만들면 사용자가 잘못된 선택을 하게 된다. + """ + if option not in DISPOSAL_OPTIONS: + raise DisposalSiteError(f"알 수 없는 선정 기준입니다: {option}") + + xy, z, chainage = _route_arrays(route_polyline) + samples, sample_z, chainages, tangent = _resample_route( + xy, z, chainage, float(DISPOSAL_SITE_CRITERIA["along_spacing_m"]) + ) + route_length_m = float(chainage[-1]) + terrain = _Terrain(project_root, filter_key, method, smooth) + valley_xy = ( + _valley_points(project_root, filter_key, method, smooth) + if option in ("stability", "valley") + else np.empty((0, 2)) + ) + canopy = _canopy_grid(project_root) if option == "clearing" else None + + result: dict[str, Any] = { + "option": option, + "spoil": [], + "borrow": [], + # 후보가 모자라 어느 부지에도 담지 못한 토량. 0이 아니면 화면이 그대로 알려야 한다 — + # 조용히 빠지면 사용자는 계획이 완성된 줄 알고 다음 단계로 넘어간다. + "unassigned_spoil_m3": 0.0, + "unassigned_borrow_m3": 0.0, + } + plans = ( + ("spoil", surplus_m3, _zone_center(surplus_zones or [])), + ("borrow", shortage_m3, _zone_center(shortage_zones or [])), + ) + for kind, demand, focus in plans: + if demand <= 0: + continue + candidates = _build_candidates( + option=option, + kind=kind, + terrain=terrain, + samples=samples, + sample_z=sample_z, + chainages=chainages, + tangent=tangent, + valley_xy=valley_xy, + canopy=canopy, + focus_m=focus, + route_length_m=route_length_m, + ) + picked = _pick(candidates, demand) + result[kind] = picked + assigned = sum(float(site["assigned_m3"]) for site in picked) + result[f"unassigned_{kind}_m3"] = round(max(demand - assigned, 0.0), 1) + result["route_length_m"] = round(route_length_m, 2) + return result diff --git a/B05_wf2_Route/B05_wf2_Route_Router.py b/B05_wf2_Route/B05_wf2_Route_Router.py index 0efb30c9..654cf9c7 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router.py +++ b/B05_wf2_Route/B05_wf2_Route_Router.py @@ -14,9 +14,10 @@ from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_wf2_Route.B05_wf2_Route_Debug import log_b05_debug from B05_wf2_Route.B05_wf2_Route_Engine import run_route_design +from B05_wf2_Route.B05_wf2_Route_Engine_Disposal import DisposalSiteError, compute_disposal_sites from B05_wf2_Route.B05_wf2_Route_Engine_Grade import GradeDesignOptions, resolve_grade_options from B05_wf2_Route.B05_wf2_Route_Engine_Grade_Profile import rebuild_alignment_profile -from B05_wf2_Route.B05_wf2_Route_Engine_Sections import run_section_generation +from B05_wf2_Route.B05_wf2_Route_Engine_Sections import _load_route_polyline, run_section_generation from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import SectionGenerationOptions from B05_wf2_Route.B05_wf2_Route_Repository import ( confirm_route, @@ -30,12 +31,15 @@ from B05_wf2_Route.B05_wf2_Route_Repository import ( ) from B05_wf2_Route.B05_wf2_Route_Router_Confirm import ( _append_irregular_cross_sections, + _merge_disposal_sites_into_longitudinal, _merge_uphill_overrides_into_longitudinal, ) from B05_wf2_Route.B05_wf2_Route_Schema import ( GRADE_PERCENT_FIELDS, ContourIntervalUpdateRequest, ContourIntervalUpdateResponse, + DisposalSitesRequest, + DisposalSitesResponse, ProfileAlignmentSaveRequest, ProfileAlignmentSaveResponse, RouteConfirmRequest, @@ -463,6 +467,60 @@ async def save_profile_alignment( ) +@router.post("/{project_id}/route/disposal-sites", response_model=DisposalSitesResponse) +async def compute_route_disposal_sites( + project_id: UUID, request: DisposalSitesRequest +) -> DisposalSitesResponse | JSONResponse: + """선정 기준 하나로 사토장·토취장 후보지를 계산한다(저장 없음, 조회 전용).""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + latest = await get_latest_route(connection, project_id) + if not latest or int(latest["id"]) != request.route_id: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "대상 경로를 찾을 수 없습니다."}, + ) + stored_path = await get_project_storage_relative_path(connection, project_id) + surface_params = await get_surface_confirmation_params(connection, str(project_id)) + project_root = Path(resolve_stored_project_path(stored_path)) + polyline = await asyncio.to_thread( + _load_route_polyline, project_root, str(latest["route_data_path"]) + ) + # DEM 격자 훑기는 이벤트 루프를 막지 않도록 별도 스레드에서 실행한다. + sites = await asyncio.to_thread( + compute_disposal_sites, + project_root, + polyline, + option=request.option, + filter_key=str(surface_params["source_filter"]), + method=str(surface_params["method"]), + smooth=bool(surface_params["smooth"]), + surplus_m3=request.surplus_m3, + shortage_m3=request.shortage_m3, + surplus_zones=[zone.model_dump() for zone in request.surplus_zones], + shortage_zones=[zone.model_dump() for zone in request.shortage_zones], + ) + return DisposalSitesResponse( + project_id=str(project_id), + route_id=request.route_id, + option=sites["option"], + spoil=sites["spoil"], + borrow=sites["borrow"], + route_length_m=sites["route_length_m"], + unassigned_spoil_m3=sites["unassigned_spoil_m3"], + unassigned_borrow_m3=sites["unassigned_borrow_m3"], + ) + except (DisposalSiteError, FileNotFoundError, ValueError) as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("B05 사토장·토취장 선정 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "후보지 계산 중 오류가 발생했습니다."}, + ) + + @router.get("/{project_id}/route/latest", response_model=RouteLatestResponse) async def read_latest_route(project_id: UUID) -> RouteLatestResponse | JSONResponse: """최신 경로와 DB 렌더 좌표, WF1/WF2 입력 스냅샷을 반환한다.""" @@ -524,6 +582,23 @@ async def confirm_latest_route( project_id, latest["id"], ) + # 사토장·토취장 선정 결과를 종단 정본에 심는다 — 비치명적. + try: + stored_path = await get_project_storage_relative_path(connection, project_id) + longitudinal = await get_longitudinal_section(connection, project_id, latest["id"]) + if longitudinal: + await asyncio.to_thread( + _merge_disposal_sites_into_longitudinal, + Path(resolve_stored_project_path(stored_path)), + str(longitudinal["longitudinal_file_path"]), + request.disposal_sites, + ) + except Exception: + logger.exception( + "B05 사토장·토취장 저장 실패 (경로 확정은 진행): project_id=%s route_id=%s", + project_id, + latest["id"], + ) # 상단측(측구 방향) 사용자 변경분을 종단 정본에 병합한다 — 비치명적. if request.uphill_overrides: try: diff --git a/B05_wf2_Route/B05_wf2_Route_Router_Confirm.py b/B05_wf2_Route/B05_wf2_Route_Router_Confirm.py index 824ecfbe..291e015d 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router_Confirm.py +++ b/B05_wf2_Route/B05_wf2_Route_Router_Confirm.py @@ -72,6 +72,29 @@ def _merge_uphill_overrides_into_longitudinal( atomic_write_json(path, data) +def _merge_disposal_sites_into_longitudinal( + project_root: Path, longitudinal_file_path: str, disposal: dict[str, Any] | None +) -> None: + """사토장·토취장 선정 결과를 종단 정본 파일에 심는다(확정 = 영구저장 시점). + + B06 정식 유토곡선이 이 값을 읽어 운반거리를 다시 재고, B08 내역서가 운반 항목으로 + 받는다. `None`이면(기준 미선택) 기존에 저장된 값을 **지운다** — 사용자가 선정을 + 풀었는데 옛 부지가 남아 있으면 다음 단계가 없는 계획을 있다고 읽는다. + """ + root = project_root.resolve() + path = (root / longitudinal_file_path).resolve() + if root not in path.parents or not path.is_file(): + return + data = json.loads(path.read_text(encoding="utf-8")) + if disposal: + data["disposal_sites"] = disposal + elif "disposal_sites" in data: + data.pop("disposal_sites") + else: + return + atomic_write_json(path, data) + + def _merge_irregular_into_longitudinal( project_root: Path, longitudinal_file_path: str, irregular_stations: list[dict[str, Any]] ) -> None: diff --git a/B05_wf2_Route/B05_wf2_Route_Schema.py b/B05_wf2_Route/B05_wf2_Route_Schema.py index 267a66fb..a2605dfe 100644 --- a/B05_wf2_Route/B05_wf2_Route_Schema.py +++ b/B05_wf2_Route/B05_wf2_Route_Schema.py @@ -258,6 +258,9 @@ class RouteConfirmRequest(BaseModel): irregular_stations: list[IrregularStationInput] = Field(default_factory=list) # 측점 상단측(측구 방향) 사용자 변경분 — solve 자동 판정을 확정 시 덮어쓴다. uphill_overrides: list[UphillSideOverride] = Field(default_factory=list) + # 사토장·토취장 선정 결과(기준 + 부지 목록). 화면 구조를 그대로 종단 정본에 심으므로 + # 서버는 형태를 강제하지 않는다 — 값의 의미는 B05 화면과 B06/B08 소비처가 함께 안다. + disposal_sites: dict[str, Any] | None = None def extra_stations(self) -> tuple[tuple[float, str], ...]: return tuple((item.chainage_m, item.structure) for item in self.irregular_stations) @@ -289,3 +292,45 @@ class RouteLatestResponse(BaseModel): route_points: list[dict[str, Any]] = Field(default_factory=list) surface_params: dict[str, Any] route_params: dict[str, Any] | None = None + + +class DisposalZone(BaseModel): + """유토곡선이 낸 잉여(사토) 또는 부족(토취) 구간 하나.""" + + model_config = ConfigDict(extra="forbid") + + from_m: float = Field(ge=0) + to_m: float = Field(ge=0) + volume_m3: float = Field(ge=0) + + +class DisposalSitesRequest(BaseModel): + """사토장·토취장 후보지 자동 선정 요청. + + 토량은 프론트 유토곡선이 계산한 값을 그대로 받는다 — 같은 곡선을 서버에서 다시 + 적분하면 화면과 어긋날 여지가 생기고, 계획 단계에서는 곡선 자체가 계속 바뀐다. + """ + + model_config = ConfigDict(extra="forbid") + + route_id: int = Field(gt=0) + option: Literal["cost", "stability", "valley", "clearing"] + surplus_m3: float = Field(default=0.0, ge=0) + shortage_m3: float = Field(default=0.0, ge=0) + surplus_zones: list[DisposalZone] = Field(default_factory=list) + shortage_zones: list[DisposalZone] = Field(default_factory=list) + + +class DisposalSitesResponse(BaseModel): + """옵션 하나에 대한 후보지 목록. `spoil`이 사토장, `borrow`가 토취장.""" + + status: str = "ok" + project_id: str + route_id: int + option: str + spoil: list[dict[str, Any]] = Field(default_factory=list) + borrow: list[dict[str, Any]] = Field(default_factory=list) + route_length_m: float = 0.0 + # 후보 부지가 모자라 담지 못한 토량(㎥). 0이 아니면 화면이 경고로 알린다. + unassigned_spoil_m3: float = 0.0 + unassigned_borrow_m3: float = 0.0 diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index 2f5cd140..c121950f 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -594,6 +594,8 @@ export async function renderB05Route(root: HTMLElement): Promise { chainage_m: Number(chainage), side, })), + // 사토장·토취장 선정 결과 — 종단 정본에 심겨 B06 정식 유토곡선과 B08 내역서가 받는다. + disposal_sites: profilePanel.disposalPayload(), }); renderLatest(await loadLatest(true)); showToast("경로를 확정했습니다.", "success"); @@ -655,6 +657,21 @@ export async function renderB05Route(root: HTMLElement): Promise { crossSampleInterval: sectionContext.defaults.cross_sample_interval_m, longSampleInterval: sectionContext.defaults.long_sample_interval_m, }); + // 계획 유토곡선용 설정 — 토량환산계수·운반장비 경계는 config_system 정의를 그대로 받아 쓰고, + // 노반폭은 표준횡단(토사)의 `노면폭 + 좌·우 길어깨`로 B06 엔진과 같은 정의를 맞춘다. + const standardSoil = sectionContext.standard_cross_section?.soil; + profilePanel.setEarthworkContext( + standardSoil + ? { + conversion: sectionContext.earthwork_conversion, + haulLimits: sectionContext.haul_equipment_limits, + roadbedWidthM: + standardSoil.road_width_m + + standardSoil.shoulder_left_m + + standardSoil.shoulder_right_m, + } + : null, + ); restorePanel(latestResponse); renderLatest(latestResponse); latest = latestResponse; diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Disposal.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Disposal.ts new file mode 100644 index 00000000..3206750f --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Disposal.ts @@ -0,0 +1,368 @@ +/* ============================================================================= + * B05_wf2_Route_UI_Profile_Disposal.ts + * 사토장·토취장 자동 선정 — 4옵션 토글과 결과 목록, 사용자 정의 편집. + * + * 토글은 계획 유토곡선 범례 줄의 [토량 분배]와 [도형 위치 초기화] **사이**에 구분기호와 + * 함께 들어간다(2026-08-03 사용자 지시). 하나를 켜면 나머지는 꺼지는 라디오 방식이다. + * + * ── 서버가 계산하는 것 / 화면이 계산하는 것 ────────────────────────── + * 후보 부지 탐색은 DEM 격자를 훑어야 해서 서버 몫이다(`/route/disposal-sites`). + * 화면은 유토곡선이 이미 낸 잔량(사토·토취 구간과 토량)을 그대로 실어 보낸다 — + * 같은 곡선을 서버에서 다시 적분하면 계획선을 끌 때마다 화면과 어긋난다. + * + * ── 사용자 정의 ───────────────────────────────────────────────────── + * 자동 선정값은 출발점일 뿐이라 위치(측점)와 용량을 손으로 고칠 수 있다. 고치면 그 부지에 + * "사용자 정의" 배지가 붙고 운반거리를 다시 잰다. [사용자 정의 삭제]로 자동값을 되돌린다. + * ========================================================================== */ + +import type { DisposalOption, DisposalSite, DisposalZone } from "./B05_wf2_Route_Api_Fetch"; +import { computeDisposalSites } from "./B05_wf2_Route_Api_Fetch"; +import type { HaulPlan } from "@util/common_util_mass_haul_balance"; +import { showToast } from "@ui/ui_template_elements"; + +/** 선택한 기준을 세션 동안 유지한다(패널 펼침·범례 토글과 같은 수명). */ +const OPTION_KEY = "b05-route-disposal-option"; + +const OPTION_LABELS: Array<{ key: DisposalOption; label: string; tip: string }> = [ + { key: "cost", label: "비용", tip: "운반거리가 짧고 트럭이 오르막을 타지 않는 자리를 고릅니다." }, + { + key: "stability", + label: "지형안정성", + tip: "평탄지·완경사지를 고르고 계곡부·하천 주변은 유실 위험으로 제외합니다.", + }, + { + key: "valley", + label: "계곡부", + tip: "배관(암거) 지점과 연계해 계곡부를 메우는 실무 방식으로 고릅니다.", + }, + { + key: "clearing", + label: "임내 공간", + tip: "라이다 식생 높이가 낮은 공터·벌채지를 우선합니다.", + }, +]; + +/** 화면이 들고 있는 부지 하나 — 서버 자동값 위에 사용자 수정분을 얹은 상태. */ +interface EditableSite extends DisposalSite { + /** 사용자가 위치나 용량을 고쳤는가. 배지 표시와 복원 대상 판정에 쓴다. */ + custom: boolean; + /** 자동 선정 당시 값 — [사용자 정의 삭제]가 여기로 되돌린다. */ + auto: { chainage_m: number; assigned_m3: number; haul_distance_m: number }; +} + +export interface RouteDisposalController { + /** 범례 줄에 끼워 넣을 토글 4개. */ + toggles: HTMLElement[]; + /** 선정 결과 목록 — 유토곡선 요약 막대 아래에 붙인다. */ + panel: HTMLElement; + setRoute(projectId: string, routeId: number | null): void; + /** 유토곡선이 다시 계산될 때마다 호출한다. 잔량이 바뀌면 결과를 다시 받는다. */ + update(plan: HaulPlan | null): void; + /** 확정 저장용 직렬화. 기준을 안 골랐으면 null. */ + payload(): Record | null; +} + +/** 잔량 목록을 서버가 받는 구간 형태로 옮긴다. */ +function zonesOf(plan: HaulPlan | null, kind: "spoil" | "borrow"): DisposalZone[] { + return (plan?.residuals ?? []) + .filter((residual) => residual.kind === kind) + .map((residual) => ({ + from_m: residual.from_m, + to_m: residual.to_m, + // 사토는 자연방토로 빠지는 몫을 뺀 나머지만 실어 내면 된다. + volume_m3: + kind === "spoil" + ? Math.max(residual.volume_m3 - residual.natural_m3, 0) + : residual.volume_m3, + })) + .filter((zone) => zone.volume_m3 > 0); +} + +function totalOf(zones: DisposalZone[]): number { + return zones.reduce((sum, zone) => sum + zone.volume_m3, 0); +} + +/** 잔량이 실질적으로 바뀌었는지 — 같은 값으로 서버를 다시 부르지 않기 위한 지문. */ +function signatureOf( + option: DisposalOption | null, + spoil: DisposalZone[], + borrow: DisposalZone[], +): string { + const round = (value: number): number => Math.round(value * 10) / 10; + const part = (zones: DisposalZone[]): string => + zones + .map((zone) => `${round(zone.from_m)}:${round(zone.to_m)}:${round(zone.volume_m3)}`) + .join(","); + return `${option ?? "-"}|${part(spoil)}|${part(borrow)}`; +} + +function metricRow(label: string, value: string): HTMLElement { + const item = document.createElement("span"); + item.className = "b05-disposal__metric"; + const caption = document.createElement("em"); + caption.textContent = label; + const amount = document.createElement("strong"); + amount.textContent = value; + item.append(caption, amount); + return item; +} + +export function createRouteDisposalPanel(): RouteDisposalController { + const panel = document.createElement("div"); + panel.className = "b05-disposal"; + panel.hidden = true; + + let projectId = ""; + let routeId: number | null = null; + let option: DisposalOption | null = + (sessionStorage.getItem(OPTION_KEY) as DisposalOption) || null; + let sites: EditableSite[] = []; + let signature = ""; + let pending = false; + let lastPlan: HaulPlan | null = null; + + const toggles = OPTION_LABELS.map(({ key, label, tip }) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "b06-masshaul__legend-item b05-disposal__toggle"; + button.textContent = label; + button.title = tip; + button.addEventListener("click", () => select(key)); + return button; + }); + + function syncToggles(): void { + toggles.forEach((button, index) => { + const active = OPTION_LABELS[index].key === option; + button.classList.toggle("is-off", !active); + button.setAttribute("aria-pressed", String(active)); + // 하나를 고르면 나머지는 비활성 — 두 기준을 섞은 결과는 근거가 설명되지 않는다. + button.disabled = pending || (option !== null && !active); + }); + } + + function select(next: DisposalOption): void { + option = option === next ? null : next; + if (option) sessionStorage.setItem(OPTION_KEY, option); + else sessionStorage.removeItem(OPTION_KEY); + sites = []; + signature = ""; + syncToggles(); + void refresh(); + } + + async function refresh(): Promise { + if (!option || !routeId || !projectId) { + panel.hidden = true; + panel.replaceChildren(); + return; + } + const spoilZones = zonesOf(lastPlan, "spoil"); + const borrowZones = zonesOf(lastPlan, "borrow"); + const next = signatureOf(option, spoilZones, borrowZones); + if (next === signature) { + render(); + return; + } + if (!spoilZones.length && !borrowZones.length) { + signature = next; + sites = []; + render("절·성토가 균형을 이뤄 사토장·토취장이 필요하지 않습니다."); + return; + } + pending = true; + syncToggles(); + render("후보지를 계산하는 중…"); + try { + const response = await computeDisposalSites(projectId, routeId, option, { + surplus_m3: totalOf(spoilZones), + shortage_m3: totalOf(borrowZones), + surplus_zones: spoilZones, + shortage_zones: borrowZones, + }); + signature = next; + sites = [...response.spoil, ...response.borrow].map((site) => ({ + ...site, + custom: false, + auto: { + chainage_m: site.chainage_m, + assigned_m3: site.assigned_m3, + haul_distance_m: site.haul_distance_m, + }, + })); + // 후보가 모자라 남은 토량은 반드시 알린다 — 조용히 빠지면 계획이 완성된 줄 안다. + const leftover = + response.unassigned_spoil_m3 > 0 || response.unassigned_borrow_m3 > 0 + ? `후보지가 부족해 사토 ${response.unassigned_spoil_m3.toLocaleString()}㎥ · ` + + `토취 ${response.unassigned_borrow_m3.toLocaleString()}㎥가 배정되지 않았습니다.` + : undefined; + render(sites.length ? leftover : "조건에 맞는 후보지를 찾지 못했습니다."); + } catch (error) { + sites = []; + render(error instanceof Error ? error.message : "후보지를 계산하지 못했습니다."); + } finally { + pending = false; + syncToggles(); + } + } + + /** 사용자가 위치를 옮기면 운반거리는 잔량 구간 중심까지의 거리로 다시 잰다. */ + function recomputeHaul(site: EditableSite): void { + const zones = zonesOf(lastPlan, site.kind); + const total = totalOf(zones); + if (total <= 0) return; + const center = + zones.reduce((sum, zone) => sum + zone.volume_m3 * 0.5 * (zone.from_m + zone.to_m), 0) / + total; + site.haul_distance_m = + Math.round((Math.abs(site.chainage_m - center) + Math.abs(site.offset_m)) * 10) / 10; + } + + function editRow(site: EditableSite): HTMLElement { + const row = document.createElement("div"); + row.className = "b05-disposal__edit"; + const add = (label: string, value: number, apply: (next: number) => void): void => { + const wrap = document.createElement("label"); + wrap.className = "b05-disposal__field"; + const caption = document.createElement("span"); + caption.textContent = label; + const input = document.createElement("input"); + input.type = "number"; + input.step = "0.1"; + input.min = "0"; + input.value = String(value); + input.addEventListener("change", () => { + const parsed = Number(input.value); + if (!Number.isFinite(parsed) || parsed < 0) { + input.value = String(value); + showToast("0 이상의 숫자를 입력하세요.", "error"); + return; + } + apply(parsed); + site.custom = true; + recomputeHaul(site); + render(); + }); + wrap.append(caption, input); + row.append(wrap); + }; + add("측점(m)", site.chainage_m, (next) => { + site.chainage_m = next; + }); + add("용량(㎥)", site.assigned_m3, (next) => { + site.assigned_m3 = next; + }); + if (site.custom) { + const reset = document.createElement("button"); + reset.type = "button"; + reset.className = "b05-disposal__reset"; + reset.textContent = "사용자 정의 삭제"; + reset.title = "이 부지를 자동 선정값으로 되돌립니다."; + reset.addEventListener("click", () => { + site.chainage_m = site.auto.chainage_m; + site.assigned_m3 = site.auto.assigned_m3; + site.haul_distance_m = site.auto.haul_distance_m; + site.custom = false; + render(); + }); + row.append(reset); + } + return row; + } + + function siteCard(site: EditableSite): HTMLElement { + const card = document.createElement("div"); + card.className = `b05-disposal__site b05-disposal__site--${site.kind}`; + const head = document.createElement("div"); + head.className = "b05-disposal__head"; + const title = document.createElement("strong"); + title.textContent = `${site.kind === "spoil" ? "사토장" : "토취장"} ${site.id.split("-")[1]}`; + head.append(title); + if (site.custom) { + const badge = document.createElement("span"); + badge.className = "b05-disposal__badge"; + badge.textContent = "사용자 정의"; + head.append(badge); + } + const metrics = document.createElement("div"); + metrics.className = "b05-disposal__metrics"; + metrics.append( + metricRow( + "위치", + `${site.chainage_m.toFixed(1)}m ${site.side === "left" ? "좌" : "우"}${Math.abs(site.offset_m).toFixed(0)}m`, + ), + metricRow( + "배정", + `${site.assigned_m3.toLocaleString()}㎥ / 수용 ${site.capacity_m3.toLocaleString()}㎥`, + ), + metricRow("운반거리", `${site.haul_distance_m.toFixed(0)}m`), + metricRow("지반경사", `${(site.mean_slope * 100).toFixed(0)}%`), + ); + // 옵션마다 사용자가 확인해야 할 근거값이 다르다 — 고른 기준에 해당하는 것만 덧붙인다. + if (option === "cost") { + metrics.append( + metricRow("낙차", `${site.drop_m.toFixed(1)}m ${site.drop_m > 0 ? "(하향)" : "(상향)"}`), + ); + } else if (option === "stability" && site.valley_distance_m !== null) { + metrics.append(metricRow("계곡 이격", `${site.valley_distance_m.toFixed(0)}m`)); + } else if (option === "valley" && site.valley_distance_m !== null) { + metrics.append(metricRow("계곡 축까지", `${site.valley_distance_m.toFixed(0)}m`)); + } else if (option === "clearing" && site.canopy_m !== null) { + metrics.append(metricRow("수고", `${site.canopy_m.toFixed(1)}m`)); + } + card.append(head, metrics, editRow(site)); + return card; + } + + function render(note?: string): void { + panel.hidden = !option; + panel.replaceChildren(); + if (!option) return; + if (note) { + const message = document.createElement("span"); + message.className = "b05-disposal__note"; + message.textContent = note; + panel.append(message); + } + for (const site of sites) panel.append(siteCard(site)); + } + + syncToggles(); + + return { + toggles, + panel, + setRoute(nextProjectId, nextRouteId) { + if (projectId === nextProjectId && routeId === nextRouteId) return; + projectId = nextProjectId; + routeId = nextRouteId; + signature = ""; + sites = []; + void refresh(); + }, + update(plan) { + lastPlan = plan; + void refresh(); + }, + payload() { + if (!option) return null; + return { + option, + sites: sites.map((site) => ({ + id: site.id, + kind: site.kind, + chainage_m: site.chainage_m, + offset_m: site.offset_m, + side: site.side, + x: site.x, + y: site.y, + elevation_m: site.elevation_m, + capacity_m3: site.capacity_m3, + assigned_m3: site.assigned_m3, + haul_distance_m: site.haul_distance_m, + custom: site.custom, + })), + }; + }, + }; +} diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul.ts new file mode 100644 index 00000000..6eba410c --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul.ts @@ -0,0 +1,206 @@ +/* ============================================================================= + * B05_wf2_Route_UI_Profile_MassHaul.ts + * B05 계획 유토곡선 — 종단면 패널 안에 다시 접히는 2차 하단 슬라이드 패널. + * + * ── 왜 B05에 유토곡선이 있는가 ─────────────────────────────────────── + * 절·성토 균형은 [임도의 설계 및 시설기준] 2.다.(3)(나)가 **종단 시공계획고**를 대상으로 + * 정한다. 사토장·토취장을 어디에 둘지도 계획고를 다시 끌어야 정리되는 문제라, 계획선 + * 편집 정본·배수유역·배관 데이터가 모두 모여 있는 이 화면에서 곡선을 보며 잡는 것이 맞다. + * B06의 횡단 기준 곡선은 실측 단면적으로 낸 **정식 물량**이고, 이쪽은 계획용 개략값이다. + * + * ── 배치 ───────────────────────────────────────────────────────── + * 펼치면 12행 도면 테이블 자리를 그대로 덮는다(2026-08-03 사용자 지시). 곡선 SVG를 종단 + * 그래프와 **같은 가로 스크롤러(canvas)** 안에 형제로 넣어 X축이 저절로 맞물리게 하고, + * 범례·요약처럼 폭에 관여하면 안 되는 것만 스크롤러 밖 막대에 둔다 — B06에서 겪은 + * "감싸는 상자 하나가 스크롤 폭 계산에 끼어들어 측점선이 어긋나는" 문제를 되풀이하지 않는다. + * ========================================================================== */ + +import type { EarthworkConversion, HaulEquipmentLimit } from "@util/common_util_mass_haul_types"; +import type { MassHaulProfile, MassHaulStationSource } from "@util/common_util_mass_haul_types"; +import type { MassHaulResult, MassHaulSeries } from "@util/common_util_mass_haul"; +import type { HaulPlan } from "@util/common_util_mass_haul_balance"; +import type { MassHaulAxis } from "@util/common_util_mass_haul_view"; +import { computeLongitudinalMassHaul, MASS_HAUL_BALANCE_KEY } from "@util/common_util_mass_haul"; +import { computeHaulPlan } from "@util/common_util_mass_haul_balance"; +import { resetBalloonOffsets } from "@util/common_util_mass_haul_balance_view"; +import { + createMassHaulChart, + createMassHaulLegend, + createMassHaulSummary, + MASS_HAUL_MIN_HEIGHT, +} from "@util/common_util_mass_haul_view"; +import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; +import { createRouteDisposalPanel } from "./B05_wf2_Route_UI_Profile_Disposal"; +import "./B05_wf2_Route_UI_Style_Disposal.css"; +import "@util/common_util_mass_haul.css"; +import "./B05_wf2_Route_UI_Style_MassHaul.css"; + +/** 2차 패널 펼침 여부 — 세션 동안만 유지(패널 높이·접힘과 같은 수명). */ +const OPEN_KEY = "b05-route-profile-masshaul-open"; +/** 표시 중인 곡선·레이어 키 보관. 곡선은 종단 하나뿐이라 사실상 분배 레이어 토글이다. */ +const VISIBLE_KEY = "b05-route-profile-masshaul-visible"; + +/** 계획 곡선은 종단 기준 하나뿐이지만, 범례·CSS 변형 클래스는 B06과 같은 키를 쓴다. */ +const PLAN_BASIS = "longitudinal" as const; + +/** 유토곡선 계산에 필요한 프로젝트 설정 — B05 Page가 `fetchSectionContext()`에서 받아 넘긴다. */ +export interface RouteMassHaulContext { + conversion: EarthworkConversion; + haulLimits?: HaulEquipmentLimit[]; + /** 표준횡단 설정의 노반폭(`road_width_m + shoulder_left_m + shoulder_right_m`). */ + roadbedWidthM: number; +} + +export interface RouteMassHaulDrawParams { + /** 측점선을 세울 목록 — 종단 그래프에 넣은 것과 **같은 배열**이어야 자리가 맞는다. */ + longitudinal: MassHaulStationSource; + /** 편집이 반영된 현재 계획선(`toDesignProfile` 결과). */ + designProfile: MassHaulProfile; + /** 종단 그래프와 같은 X 매핑(누가거리 최댓값·좌우 여백). */ + axis: MassHaulAxis; + stationInterval: number; + widthPx: number; + heightPx: number; + selectedStationId: string | null; + onSelectStation: (stationId: string) => void; +} + +export interface RouteMassHaulPanel { + /** 종단면 패널 바닥에 붙는 2차 슬라이드 손잡이. */ + handle: HTMLElement; + /** 범례·요약 막대 — 가로 스크롤러 **밖**에 놓는다. */ + bar: HTMLElement; + isOpen(): boolean; + setContext(next: RouteMassHaulContext | null): void; + /** 사토장·토취장 선정이 어느 경로를 대상으로 하는지 알린다. */ + setRoute(projectId: string, routeId: number | null): void; + /** 확정 저장에 실을 사토장·토취장 결과. 기준을 안 골랐으면 null. */ + disposalPayload(): Record | null; + /** 마지막으로 계산한 곡선(사토장·토취장 선정이 잉여/부족 입력으로 되받는다). */ + result(): MassHaulResult | null; + /** + * 곡선 SVG를 만든다. 펼침 상태가 아니거나 계산이 안 되면 null을 돌려주고, + * 이때 호출한 쪽은 원래대로 도면 테이블을 그린다. + */ + draw(params: RouteMassHaulDrawParams): SVGSVGElement | null; +} + +/** + * @param onChanged 펼침·범례 토글로 다시 그려야 할 때 호출된다(패널 전체 redraw). + */ +export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPanel { + const handleControl = createWorkflowPanelHandle("bottom", "down"); + const handle = document.createElement("div"); + handle.className = "b05-profile__masshaul-handle"; + const caption = document.createElement("span"); + caption.className = "b05-profile__masshaul-caption"; + caption.textContent = "계획 유토곡선"; + handle.append(handleControl.root, caption); + + const bar = document.createElement("div"); + bar.className = "b05-profile__masshaul-bar"; + const disposal = createRouteDisposalPanel(); + + let open = sessionStorage.getItem(OPEN_KEY) === "true"; + let context: RouteMassHaulContext | null = null; + let lastResult: MassHaulResult | null = null; + const stored = sessionStorage.getItem(VISIBLE_KEY); + const visible = new Set( + stored ? (JSON.parse(stored) as string[]) : [PLAN_BASIS, MASS_HAUL_BALANCE_KEY], + ); + + function applyOpen(next: boolean): void { + open = next; + sessionStorage.setItem(OPEN_KEY, String(next)); + handleControl.setOpen(next); + handle.classList.toggle("is-open", next); + bar.hidden = !next; + onChanged(); + } + handleControl.setOpen(open); + handle.classList.toggle("is-open", open); + bar.hidden = !open; + handleControl.root.addEventListener("click", () => applyOpen(!open)); + caption.addEventListener("click", () => applyOpen(!open)); + + function toggleSeries(key: string): void { + if (visible.has(key)) visible.delete(key); + else visible.add(key); + sessionStorage.setItem(VISIBLE_KEY, JSON.stringify([...visible])); + onChanged(); + } + + return { + handle, + bar, + isOpen: () => open, + setContext(next) { + context = next; + }, + setRoute(projectId, routeId) { + disposal.setRoute(projectId, routeId); + }, + disposalPayload: () => disposal.payload(), + result: () => lastResult, + draw(params) { + bar.replaceChildren(); + lastResult = null; + if (!open) return null; + if (!context) { + const note = document.createElement("span"); + note.className = "b05-profile__masshaul-empty"; + note.textContent = "토량환산계수를 불러오지 못해 유토곡선을 계산할 수 없습니다."; + bar.append(note); + return null; + } + const result = computeLongitudinalMassHaul( + { length_m: 0, design_profiles: [params.designProfile] }, + context.conversion, + { roadbedWidthM: context.roadbedWidthM }, + ); + if (!result) { + disposal.update(null); + const note = document.createElement("span"); + note.className = "b05-profile__masshaul-empty"; + note.textContent = "계획선이 아직 없어 유토곡선을 그릴 수 없습니다."; + bar.append(note); + return null; + } + lastResult = result; + const series: MassHaulSeries[] = [{ key: PLAN_BASIS, basis: PLAN_BASIS, result }]; + const haulPlan: HaulPlan | null = visible.has(MASS_HAUL_BALANCE_KEY) + ? computeHaulPlan(result, context.haulLimits) + : null; + const chart = createMassHaulChart( + series, + visible, + params.longitudinal, + params.axis, + params.selectedStationId, + params.stationInterval, + params.widthPx, + Math.max(params.heightPx, MASS_HAUL_MIN_HEIGHT), + params.widthPx, + params.onSelectStation, + haulPlan, + ); + bar.append( + createMassHaulLegend( + series, + visible, + toggleSeries, + () => { + resetBalloonOffsets(); + onChanged(); + }, + disposal.toggles, + ), + createMassHaulSummary(series[0], haulPlan), + disposal.panel, + ); + // 잔량(사토·토취 구간)이 바뀌었을 때만 서버를 다시 부른다 — 안에서 지문으로 거른다. + disposal.update(haulPlan); + return chart; + }, + }; +} diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts index 501f0712..1607be44 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -47,6 +47,10 @@ import { toAlignmentBase, } from "./B05_wf2_Route_UI_Profile_Alignment"; import { createEditOverlay, createProfileEditStore } from "./B05_wf2_Route_UI_Profile_Edit"; +import { + createRouteMassHaulPanel, + type RouteMassHaulContext, +} from "./B05_wf2_Route_UI_Profile_MassHaul"; import { createProfileTable, tableCellWidthFor, @@ -260,6 +264,9 @@ export function createRouteProfilePanel( const bodyWrap = document.createElement("div"); bodyWrap.className = "b05-route-profile__body-wrap"; bodyWrap.append(body, progress.root); + // 계획 유토곡선 — 2차 하단 슬라이드. 펼치면 도면 테이블 자리를 덮는다. + const massHaul = createRouteMassHaulPanel(() => draw()); + bodyWrap.append(massHaul.bar, massHaul.handle); const content = document.createElement("div"); content.className = "b05-route-profile__content"; // 관 목록이 바뀌면 종단 테이블의 "배관" 구조물 라인도 같이 맞춘다(정본은 관 지점 파일). @@ -438,32 +445,34 @@ export function createRouteProfilePanel( : available; const tableHeight = available - chartHeight; - const table = alignment - ? createProfileTable({ - alignment, - stationInterval: stationInterval ?? alignment.policy.station_interval_m, - width, - height: tableHeight, - // 셀 폭은 실제 측점 간격에 맞춰 함께 늘어난다(폭맞춤 시 값이 넓게 퍼진다). - cellWidth: layout.cellWidth, - // 이름표 열을 그래프 좌측 여백과 같은 폭으로 맞춰야 그래프 시작점이 가려지지 않는다. - labelWidth: LONG_PAD.left, - rowCount: TABLE_ROW_COUNT, - x, - // 비정규 측점은 규칙 격자를 건드리지 않고, 선택된 측점만 값 열로 오버레이한다. - irregularStations: irregularStations.filter( - (entry) => - entry.chainage_m >= 0 && entry.chainage_m <= maxChainageOf(longitudinal) + 1e-6, - ), - selectedStationId, - stationDisplay, - onCurveRadiusChange: (curve, radius) => - applyEdits(setCurveRadius(store.edits(), curve, radius ?? 0)), - // 값 열 계획고 직접 입력 → 규칙 측점과 동일한 station_offset 파이프라인. - onAdjustStation: (chainage, delta) => - base && applyEdits(adjustStation(base, store.edits(), chainage, delta)), - }) - : null; + // 계획 유토곡선을 펼치면 도면 테이블은 그리지 않는다 — 같은 자리를 나눠 쓰면 둘 다 뭉개진다. + const table = + alignment && !massHaul.isOpen() + ? createProfileTable({ + alignment, + stationInterval: stationInterval ?? alignment.policy.station_interval_m, + width, + height: tableHeight, + // 셀 폭은 실제 측점 간격에 맞춰 함께 늘어난다(폭맞춤 시 값이 넓게 퍼진다). + cellWidth: layout.cellWidth, + // 이름표 열을 그래프 좌측 여백과 같은 폭으로 맞춰야 그래프 시작점이 가려지지 않는다. + labelWidth: LONG_PAD.left, + rowCount: TABLE_ROW_COUNT, + x, + // 비정규 측점은 규칙 격자를 건드리지 않고, 선택된 측점만 값 열로 오버레이한다. + irregularStations: irregularStations.filter( + (entry) => + entry.chainage_m >= 0 && entry.chainage_m <= maxChainageOf(longitudinal) + 1e-6, + ), + selectedStationId, + stationDisplay, + onCurveRadiusChange: (curve, radius) => + applyEdits(setCurveRadius(store.edits(), curve, radius ?? 0)), + // 값 열 계획고 직접 입력 → 규칙 측점과 동일한 station_offset 파이프라인. + onAdjustStation: (chainage, delta) => + base && applyEdits(adjustStation(base, store.edits(), chainage, delta)), + }) + : null; const chartWrap = document.createElement("div"); chartWrap.className = "b05-profile__chart"; @@ -545,9 +554,32 @@ export function createRouteProfilePanel( }), ); } + // 유토곡선 SVG는 종단 그래프와 **같은 부모의 형제**로 넣는다. 감싸는 상자를 하나라도 + // 끼우면 그 상자가 스크롤 컨테이너 폭 계산에 끼어들어 두 그래프의 측점선이 어긋난다. + const massHaulChart = + alignment && designProfiles[0] + ? massHaul.draw({ + longitudinal: graphLongitudinal, + designProfile: designProfiles[0], + axis: { + maxChainageM: maxChainageOf(longitudinal), + // 종단 그래프의 `chainageMapper`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를 + // 좌우 여백에 합쳐 넘긴다 — 어긋나면 같은 측점이 두 그래프에서 다른 자리에 선다. + padLeft: LONG_PAD.left + originOffset, + padRight: LONG_PAD.right + originOffset, + }, + stationInterval: stationIntervalM ?? 1, + widthPx: width, + heightPx: tableHeight, + selectedStationId, + onSelectStation, + }) + : null; + canvas.style.height = `${available}px`; canvas.append(chartWrap); if (table) canvas.append(table); + if (massHaulChart) canvas.append(massHaulChart); body.replaceChildren(canvas); body.scrollLeft = scrollLeft; } @@ -611,9 +643,19 @@ export function createRouteProfilePanel( // 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지). if (carried) store.replace(carried); alignment = base ? buildAlignment(base, store.edits()) : null; + // 사토장·토취장 선정은 경로 단위 결과라 대상 경로가 바뀌면 다시 받아야 한다. + massHaul.setRoute(projectId, routeId); draw(); requestAnimationFrame(draw); }, + /** + * 계획 유토곡선 계산에 필요한 프로젝트 설정(토량환산계수·운반장비 경계·노반폭). + * Page가 `fetchSectionContext()` 응답에서 뽑아 넘긴다 — 프론트에 사본을 두지 않는다. + */ + setEarthworkContext(next: RouteMassHaulContext | null) { + massHaul.setContext(next); + draw(); + }, setSelectedStation(stationId: string | null) { selectedStationId = stationId; draw(); @@ -642,6 +684,11 @@ export function createRouteProfilePanel( if (edits.station_offsets[key] === undefined && edits.curve_radii[key] === undefined) return; store.resetStation(chainageM); }, + /** + * 확정 저장에 실을 사토장·토취장 선정 결과(기준·부지 목록·사용자 정의 여부). + * 기준을 고르지 않았으면 null이라 확정 페이로드에서 통째로 빠진다. + */ + disposalPayload: () => massHaul.disposalPayload(), isDirty: () => store.dirty(), /** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */ async save(): Promise { diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style_Disposal.css b/B05_wf2_Route/B05_wf2_Route_UI_Style_Disposal.css new file mode 100644 index 00000000..5e7664bf --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style_Disposal.css @@ -0,0 +1,121 @@ +/* ============================================================================= + * B05_wf2_Route_UI_Style_Disposal.css + * 사토장·토취장 자동 선정 토글과 결과 카드 스타일. + * + * 토글은 유토곡선 범례 줄 안에 들어가므로 범례 항목(`b06-masshaul__legend-item`)의 + * 모양을 그대로 물려받고, 여기서는 라디오처럼 보이게 하는 상태 표시만 얹는다. + * ========================================================================== */ + +/* 다른 기준이 선택돼 잠긴 토글 — 눌리지 않는다는 것이 보여야 한다. */ +.b05-disposal__toggle:disabled { + cursor: default; + opacity: 0.45; +} + +.b05-disposal__toggle:not(.is-off) { + border-color: var(--color-primary); + color: var(--color-primary); + font-weight: 600; +} + +.b05-disposal { + display: flex; + flex-wrap: wrap; + align-items: stretch; + gap: var(--spacing-8); + padding: var(--spacing-8) var(--spacing-16); + border-top: 1px solid var(--color-border); +} + +.b05-disposal__note { + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.b05-disposal__site { + display: flex; + min-width: 260px; + flex-direction: column; + gap: var(--spacing-4); + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-left-width: 3px; + border-radius: var(--radius-small); + background: var(--color-surface-raised); + font-size: var(--text-caption); +} + +/* 사토(내보내는 흙)와 토취(들여오는 흙)는 방향이 반대라 왼쪽 띠 색으로 갈라 놓는다. */ +.b05-disposal__site--spoil { + border-left-color: var(--color-warning, #d97706); +} + +.b05-disposal__site--borrow { + border-left-color: var(--color-info, #2563eb); +} + +.b05-disposal__head { + display: flex; + align-items: center; + gap: var(--spacing-8); +} + +.b05-disposal__badge { + padding: 0 var(--spacing-4); + border: 1px solid var(--color-primary); + border-radius: var(--radius-pills); + color: var(--color-primary); + font-size: 11px; +} + +.b05-disposal__metrics { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4) var(--spacing-8); + color: var(--color-text-secondary); +} + +.b05-disposal__metric { + display: inline-flex; + align-items: baseline; + gap: var(--spacing-4); +} + +.b05-disposal__metric strong { + color: var(--color-text-body); +} + +.b05-disposal__edit { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--spacing-8); +} + +.b05-disposal__field { + display: flex; + flex-direction: column; + gap: 2px; + color: var(--color-text-secondary); +} + +.b05-disposal__field input { + width: 96px; + box-sizing: border-box; + padding: 2px var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-small); + background: var(--color-surface); + color: var(--color-text-body); + font-size: var(--text-caption); +} + +.b05-disposal__reset { + padding: 2px var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-pills); + background: var(--color-surface); + color: var(--color-text-secondary); + font-size: 11px; + cursor: pointer; +} diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style_MassHaul.css b/B05_wf2_Route/B05_wf2_Route_UI_Style_MassHaul.css new file mode 100644 index 00000000..7111d0ce --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style_MassHaul.css @@ -0,0 +1,74 @@ +/* ============================================================================= + * B05_wf2_Route_UI_Style_MassHaul.css + * B05 계획 유토곡선 2차 슬라이드 패널 전용 스타일. + * + * 곡선·범례·요약 자체의 스타일은 공용(`common_util_mass_haul.css`)이 이미 정의한다. + * 여기서는 종단면 패널 안에서 그것들이 놓이는 **자리**만 잡는다. + * ========================================================================== */ + +/* 본문 칸을 세로로 쌓아 그래프 스크롤러 아래에 요약 막대·손잡이가 붙게 한다. */ +.b05-route-profile__body-wrap { + flex-direction: column; +} + +.b05-profile__masshaul-bar { + display: flex; + flex: none; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8) var(--spacing-16); + border-top: 1px solid var(--color-border); + background: var(--color-surface); +} + +/* 공용 범례는 B06에서 그래프 위에 절대배치로 띄우지만, 여기서는 막대 안에 줄로 눕힌다. */ +.b05-profile__masshaul-bar .b06-masshaul__legend { + position: static; + right: auto; + flex-wrap: wrap; + padding: var(--spacing-4) var(--spacing-16); +} + +.b05-profile__masshaul-bar .b06-masshaul__summary { + border-top: none; + padding-top: 0; +} + +.b05-profile__masshaul-empty { + padding: var(--spacing-8) var(--spacing-16); + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +/* 2차 슬라이드 손잡이 — 바깥 패널 손잡이와 같은 문법이되 본문 폭 안에 눕혀 놓는다. */ +.b05-profile__masshaul-handle { + position: relative; + display: flex; + flex: none; + align-items: center; + justify-content: center; + gap: var(--spacing-8); + height: 20px; + border-top: 1px solid var(--color-border); + background: var(--color-surface-raised); +} + +.b05-profile__masshaul-caption { + color: var(--color-text-secondary); + font-size: var(--text-caption); + line-height: 1; + cursor: pointer; + user-select: none; +} + +.b05-profile__masshaul-handle.is-open .b05-profile__masshaul-caption { + color: var(--color-text-body); + font-weight: 600; +} + +/* 공용 손잡이 버튼은 부모 바닥 경계에 절대배치되도록 만들어졌다 — 이 줄에서는 흐름에 둔다. */ +.b05-profile__masshaul-handle .ui-workflow-overlay__handle { + position: static; + transform: none; + margin: 0; +} diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts index 919f7089..9d07d295 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts @@ -15,6 +15,7 @@ * ========================================================================== */ import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; +import type { DisposalSitesSnapshot } from "@util/common_util_mass_haul_sites"; import type { BalloonOffsets, EarthworkConversion, @@ -181,6 +182,11 @@ export interface LongitudinalSection { * 구조는 `B05_wf2_Route_UI_Profile_Alignment.ProfileAlignment`. */ profile_alignment?: unknown; + /** + * B05 계획 단계에서 고른 사토장·토취장(확정 시 종단 정본에 심긴다). + * B06은 고르지 않고 **받아 쓰기만** 한다 — 부지 선정은 계획선을 바꾸는 문제라 B05 몫이다. + */ + disposal_sites?: DisposalSitesSnapshot; } export interface CrossSection extends SectionStation { diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts index 5af3f0f7..da08228b 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -36,6 +36,7 @@ import { } from "./B06_wf3_ProfileCross_UI_Section_View"; import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul"; import { computeHaulPlan } from "@util/common_util_mass_haul_balance"; +import { disposalSitesPayload } from "@util/common_util_mass_haul_sites"; import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view"; import { designElevationAt } from "./B06_wf3_ProfileCross_UI_Section_Common"; import { @@ -415,13 +416,16 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { context.natural_spoil_min_ground_slope ?? undefined, ) : null; + const haulPlan = result ? computeHaulPlan(result, context?.haul_equipment_limits) : null; return { crossPatches, massHaul: result ? massHaulPayload( result, - computeHaulPlan(result, context?.haul_equipment_limits), + haulPlan, balloonOffsetsPayload(), + // B05 선정 결과를 정식 곡선 기준 운반거리로 다시 재 실어 B08이 그대로 받게 한다. + disposalSitesPayload(sectionDetail?.longitudinal.disposal_sites, haulPlan), ) : undefined, }; diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts index 63aaba81..48998180 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts @@ -43,6 +43,7 @@ import { configureBalloonOffsets, resetBalloonOffsets, } from "@util/common_util_mass_haul_balance_view"; +import { createDisposalSummary } from "@util/common_util_mass_haul_sites"; import { createMassHaulChart, createMassHaulLegend, @@ -526,6 +527,9 @@ export function createSectionView( if (summarySeries) { panelCount.textContent = ""; panelBody.append(createMassHaulSummary(summarySeries, haulPlan)); + // B05가 고른 사토장·토취장 — 정식 곡선의 잔량 위치로 운반거리를 다시 재 표시한다. + const disposal = createDisposalSummary(detail.longitudinal.disposal_sites, haulPlan); + if (disposal) panelBody.append(disposal); } else { panelCount.textContent = L(series.length ? "B06_MassHaul_AllHidden" : "B06_MassHaul_Empty"); } diff --git a/common_util/common_util_mass_haul.css b/common_util/common_util_mass_haul.css index 88a1f5c9..c70cf9b0 100644 --- a/common_util/common_util_mass_haul.css +++ b/common_util/common_util_mass_haul.css @@ -353,3 +353,17 @@ border-top-color: var(--color-warning); border-top-style: dashed; } + +/* 범례 줄 안에서 컨트롤 묶음을 가르는 세로 구분기호(사토장·토취장 토글 양옆). */ +.b06-masshaul__legend-divider { + width: 1px; + height: 16px; + flex: none; + background: var(--color-border); +} + +/* 사토장·토취장 줄 — 요약 줄과 같은 양식이되 위 줄과 이어 붙은 것으로 읽히게 경계를 지운다. */ +.b06-masshaul__summary--sites { + border-top: none; + padding-top: 0; +} diff --git a/common_util/common_util_mass_haul.ts b/common_util/common_util_mass_haul.ts index df9da3e0..65d25a94 100644 --- a/common_util/common_util_mass_haul.ts +++ b/common_util/common_util_mass_haul.ts @@ -392,6 +392,61 @@ function longitudinalAreaSamples( }); } +/** + * 횡단 설계가 아직 없을 때 쓰는 종단 개략 곡선의 입력. + * + * B05 노선 설계 단계에는 측점별 횡단 설계(`CrossDesign`)가 없다 — 그건 B06에서 만든다. + * 그래서 노반폭을 표준횡단 설정(`road_width_m + shoulder_left_m + shoulder_right_m`)에서 + * 한 값으로 받아 전 구간에 같게 물린다. 계획선을 끌어 옮기며 절·성토 균형을 잡는 + * 용도라 이 정도 개략값이면 충분하고, 정식 물량은 B06 횡단 기준 곡선이 낸다. + */ +export interface LongitudinalMassHaulOptions { + /** 전 구간 공통 노반폭(m). */ + roadbedWidthM: number; + /** + * 절토 전량에 물릴 지반유형. B05는 암반 경계선을 모르므로 기본값은 토사이며, + * 그만큼 다짐환산이 보수적으로(작게) 잡힌다. + */ + groundType?: GroundType; +} + +/** + * 횡단 설계 없이 종단 계획선만으로 개략 유토곡선을 만든다(B05 계획 단계). + * + * `difference_m = 계획고 − 지반고`이므로 음수면 절토, 양수면 성토다. 단면적은 그 높이차에 + * 노반폭을 곱한 사각형으로 어림한다 — 비탈면 몫이 빠져 실제보다 작게 나오지만, 국내 + * 설계기준이 종단 기반 토량을 노선계획 단계의 **개산용**으로만 두고 별도 산식을 정하지 + * 않으므로(2026-08-03 조사) 표준단면까지 씌워 정밀화하지는 않는다. + * 자연방토는 성토측 지반 경사를 알 수 없어 전부 불가로 본다(보수적). + */ +export function computeLongitudinalMassHaul( + longitudinal: MassHaulLongitudinal, + conversion: EarthworkConversion, + options: LongitudinalMassHaulOptions, +): MassHaulResult | null { + const profile = longitudinal.design_profiles?.[0]; + const width = finiteArea(options.roadbedWidthM); + if (!profile || !(width > 0)) return null; + const ground = normalizeGround(options.groundType); + const samples = profile.samples + .filter((sample) => Number.isFinite(sample.chainage_m) && Number.isFinite(sample.difference_m)) + .sort((a, b) => a.chainage_m - b.chainage_m); + const areas: AreaSample[] = samples.map((sample, index) => { + const height = sample.difference_m; + const cutArea = height < 0 ? -height * width : 0; + return { + station_id: `plan@${index}`, + chainage_m: sample.chainage_m, + cut_soil_area_m2: ground === "soil" ? cutArea : 0, + cut_rock_area_m2: ground === "soil" ? 0 : cutArea, + rock_kind: ground === "soil" ? null : ground, + fill_area_m2: height > 0 ? height * width : 0, + natural_spoil: false, + }; + }); + return integrate(areas, conversion); +} + /** * 측점별 설계 결과에서 유토곡선을 계산한다(횡단 기준·지반유형별 환산). * 확정 저장과 B08 인계에 쓰는 **정식 곡선**이라 시그니처를 그대로 유지한다. @@ -438,12 +493,16 @@ export function massHaulPayload( result: MassHaulResult, haulPlan?: HaulPlan | null, balloonOffsets?: Record, + /** B05가 고른 사토장·토취장 — 정식 곡선 기준으로 운반거리를 다시 잰 형태로 받는다. */ + disposalSites?: Record, ): Record { const round = (value: number): number => Math.round(value * 100) / 100; return { basis: "compacted", conversion: result.conversion, ...(haulPlan ? { haul_plan: haulPlanPayload(haulPlan) } : {}), + // B08 내역서가 사토·토취 운반 항목을 세우는 입력. 선정이 없으면 키 자체가 빠진다. + ...(disposalSites ? { disposal_sites: disposalSites } : {}), // 사용자가 끌어 옮긴 balloon 위치 — 비어 있어도 보낸다(초기화가 저장에 반영돼야 한다). ...(balloonOffsets ? { balloon_offsets: balloonOffsets } : {}), cut_natural_m3: { diff --git a/common_util/common_util_mass_haul_sites.ts b/common_util/common_util_mass_haul_sites.ts new file mode 100644 index 00000000..2c8f1350 --- /dev/null +++ b/common_util/common_util_mass_haul_sites.ts @@ -0,0 +1,126 @@ +/* ============================================================================= + * common_util_mass_haul_sites.ts + * 사토장·토취장 선정 결과를 유토곡선 옆에 표시하고 운반거리를 다시 재는 공용 모듈. + * + * 부지를 **고르는** 일은 B05 계획 단계에서 끝난다(계획선을 바꿔야 정리되는 문제라서다). + * B06은 그 결과를 그대로 받아, 실측 단면적으로 낸 **정식 곡선**의 잔량 위치를 기준으로 + * 운반거리를 다시 재 표시하고 B08 내역서로 넘긴다 — 계획 곡선과 정식 곡선은 잔량이 + * 놓이는 자리가 다르므로, B05가 적어 둔 거리를 그대로 쓰면 물량과 어긋난다. + * ========================================================================== */ + +import type { HaulPlan } from "./common_util_mass_haul_balance"; + +/** B05가 종단 정본(`longitudinal.disposal_sites`)에 심어 둔 부지 하나. */ +export interface DisposalSiteRecord { + id: string; + kind: "spoil" | "borrow"; + chainage_m: number; + offset_m: number; + side: "left" | "right"; + x: number; + y: number; + elevation_m: number; + capacity_m3: number; + assigned_m3: number; + /** B05 계획 곡선 기준 운반거리(m). 표시·저장에는 아래 재계산값을 쓴다. */ + haul_distance_m: number; + /** 사용자가 위치나 용량을 손으로 고친 부지인가. */ + custom: boolean; +} + +export interface DisposalSitesSnapshot { + /** 선정 기준 — `cost` | `stability` | `valley` | `clearing`. */ + option: string; + sites: DisposalSiteRecord[]; +} + +const OPTION_LABEL: Record = { + cost: "비용", + stability: "지형안정성", + valley: "계곡부", + clearing: "임내 공간", +}; + +/** 잔량이 몰려 있는 대표 위치(m). 없으면 null. */ +function residualCenter(plan: HaulPlan | null, kind: "spoil" | "borrow"): number | null { + const residuals = (plan?.residuals ?? []).filter((residual) => residual.kind === kind); + const total = residuals.reduce( + (sum, residual) => + sum + + (kind === "spoil" + ? Math.max(residual.volume_m3 - residual.natural_m3, 0) + : residual.volume_m3), + 0, + ); + if (total <= 0) return null; + const weighted = residuals.reduce((sum, residual) => { + const volume = + kind === "spoil" ? Math.max(residual.volume_m3 - residual.natural_m3, 0) : residual.volume_m3; + return sum + volume * 0.5 * (residual.from_m + residual.to_m); + }, 0); + return weighted / total; +} + +/** 정식 곡선의 잔량 위치를 기준으로 각 부지의 운반거리를 다시 잰 목록. */ +export function resolveDisposalSites( + snapshot: DisposalSitesSnapshot | null | undefined, + plan: HaulPlan | null, +): DisposalSiteRecord[] { + if (!snapshot?.sites?.length) return []; + const centers = { + spoil: residualCenter(plan, "spoil"), + borrow: residualCenter(plan, "borrow"), + }; + return snapshot.sites.map((site) => { + const center = centers[site.kind]; + if (center === null) return site; + const distance = Math.abs(site.chainage_m - center) + Math.abs(site.offset_m); + return { ...site, haul_distance_m: Math.round(distance * 10) / 10 }; + }); +} + +/** + * 곡선 아래 요약 줄 다음에 붙는 사토장·토취장 줄. 선정 결과가 없으면 null. + * 표시 수치는 정식 곡선 기준으로 다시 잰 값이라 B05 화면과 거리가 다를 수 있다. + */ +export function createDisposalSummary( + snapshot: DisposalSitesSnapshot | null | undefined, + plan: HaulPlan | null, +): HTMLElement | null { + const sites = resolveDisposalSites(snapshot, plan); + if (!sites.length) return null; + const row = document.createElement("div"); + row.className = "b06-masshaul__summary b06-masshaul__summary--sites"; + const basis = document.createElement("span"); + basis.className = "b06-masshaul__basis"; + basis.textContent = `선정 기준 ${OPTION_LABEL[snapshot?.option ?? ""] ?? snapshot?.option ?? "-"}`; + row.append(basis); + for (const site of sites) { + const chip = document.createElement("span"); + chip.className = "b06-masshaul__chip"; + const name = document.createElement("em"); + name.textContent = `${site.kind === "spoil" ? "사토장" : "토취장"}${site.custom ? "*" : ""}`; + const value = document.createElement("strong"); + value.textContent = + `${site.chainage_m.toFixed(1)}m ${site.side === "left" ? "좌" : "우"}` + + `${Math.abs(site.offset_m).toFixed(0)}m · ${site.assigned_m3.toLocaleString()}㎥ · ` + + `L=${site.haul_distance_m.toFixed(0)}m`; + chip.append(name, value); + row.append(chip); + } + const note = document.createElement("span"); + note.className = "b06-masshaul__chip"; + note.textContent = "* 사용자 정의"; + row.append(note); + return row; +} + +/** 확정 저장·B08 인계용 직렬화. 운반거리는 정식 곡선 기준으로 다시 잰 값을 싣는다. */ +export function disposalSitesPayload( + snapshot: DisposalSitesSnapshot | null | undefined, + plan: HaulPlan | null, +): Record | undefined { + const sites = resolveDisposalSites(snapshot, plan); + if (!sites.length) return undefined; + return { option: snapshot?.option, sites }; +} diff --git a/common_util/common_util_mass_haul_view.ts b/common_util/common_util_mass_haul_view.ts index 232b8096..6ba26fac 100644 --- a/common_util/common_util_mass_haul_view.ts +++ b/common_util/common_util_mass_haul_view.ts @@ -508,6 +508,11 @@ export function createMassHaulLegend( visibleKeys: ReadonlySet, onToggle: (key: string) => void, onResetBalloons?: () => void, + /** + * 토량 분배 토글과 도형 위치 초기화 버튼 **사이**에 끼워 넣을 컨트롤(양쪽에 구분기호가 선다). + * B05 사토장·토취장 선정 토글이 이 자리에 들어간다(2026-08-03 사용자 지시). + */ + extras?: HTMLElement[], ): HTMLElement { const legend = document.createElement("div"); legend.className = "b06-masshaul__legend"; @@ -539,6 +544,15 @@ export function createMassHaulLegend( L("B06_MassHaul_Balance_Layer"), "b06-masshaul__legend-item b06-masshaul__legend-item--balance", ); + if (extras?.length) { + const divider = (): HTMLElement => { + const line = document.createElement("span"); + line.className = "b06-masshaul__legend-divider"; + line.setAttribute("aria-hidden", "true"); + return line; + }; + legend.append(divider(), ...extras, divider()); + } // 끌어 옮긴 balloon을 한 번에 자동 배치로 되돌린다(프론트 캐시 + 다음 확정 시 영구저장소). if (onResetBalloons && visibleKeys.has(MASS_HAUL_BALANCE_KEY)) { const reset = document.createElement("button"); diff --git a/config/config_system.py b/config/config_system.py index 69b04c5c..cb596a9b 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -481,6 +481,46 @@ EARTHWORK_HAUL_EQUIPMENT_LIMITS_M = ( NATURAL_SPOIL_MIN_GROUND_SLOPE = 1.0 / 1.5 +# ───────────────────────────────────────────────────────────────────────── +# 5-4-6. 사토장·토취장 후보지 자동 선정 (B05 계획 유토곡선) +# +# 유토곡선이 낸 잉여(사토)·부족(토취) 토량을 어디서 처리할지 노선 주변 지형에서 찾는다. +# 실무 선정 기준(토목시공학 계열 정리, 2026-08-03 조사)을 격자 판정으로 옮긴 값이다: +# 사토장 — 평탄지·완경사지, 계곡부·하천 주변은 집중호우 유실 위험으로 제외, +# 트럭이 오르막을 타지 않도록 하향 운반 우대 +# 토취장 — 양질의 토사·리핑암, 부족 구간에서 최단거리 +# +# 탐색은 **노선 좌우 corridor 안**에서만 한다(2026-08-03 사용자 확정) — 노선에서 먼 곳은 +# 운반비가 실무적 의미를 잃고 계산만 무거워진다. 값은 여기가 유일한 정의처다. +# ───────────────────────────────────────────────────────────────────────── +DISPOSAL_SITE_CRITERIA = { + # 노선 중심선 좌우로 훑는 폭(m). 이 밖은 후보로 보지 않는다. + "corridor_half_width_m": 80.0, + # 노선을 따라가며 후보를 찍는 간격(m)과 좌우로 벌리는 간격(m). + "along_spacing_m": 20.0, + "offset_spacing_m": 10.0, + # 후보 중심에서 평탄성을 재는 반경(m). 이 안의 격자 경사로 부지 적합성을 판정한다. + "flat_radius_m": 15.0, + # 사토장 허용 지반경사 상한(rise/run). 넘으면 쌓은 흙이 흘러내려 부지로 못 쓴다. + "max_ground_slope": 0.25, + # 토취장 허용 지반경사 상한 — 파내는 쪽이라 사토장보다 관대하다. + "borrow_max_ground_slope": 0.45, + # 부지에 쌓을 수 있다고 보는 평균 성토 두께(m). 용량 = 평탄면적 × 이 값. + "fill_depth_m": 3.0, + # 토취장에서 파낼 수 있다고 보는 평균 굴착 깊이(m). + "borrow_depth_m": 4.0, + # 계곡 축(skeleton valley)에서 이 거리 안은 유실 위험 구역으로 감점한다(m). + # `valley` 옵션에서는 반대로 이 안쪽만 후보로 삼는다(암거 연계 매립). + "valley_buffer_m": 25.0, + # 옵션별로 돌려주는 후보지 개수 상한(사토·토취 각각). + "max_candidates": 5, + # 식생 높이가 이 값 이하인 격자를 공터·벌채지로 본다(m). `clearing` 옵션 판정 기준. + "clearing_max_canopy_m": 2.0, + # 공터 판정에 쓰는 라이다 격자 크기(m). 너무 잘면 점이 없는 칸이 늘어 판정이 흔들린다. + "clearing_grid_m": 5.0, +} + + # ───────────────────────────────────────────────────────────────────────── # 5-5. 종단 계획선(계획고) 설계 기준 (B05 WF2) #