feat(B05): 계획 유토곡선 이관 + 사토장·토취장 4옵션 자동 선정

절·성토 균형은 종단 시공계획고로 정해지고 사토장 위치도 계획고를 다시 끌어야
정리되므로, 계획용 유토곡선과 부지 선정을 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) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 19:06:46 +09:00
co-authored by Claude Opus 5
parent fceec5fb24
commit 6c90c26eef
19 changed files with 1769 additions and 28 deletions
+66
View File
@@ -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<string, unknown> | 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<DisposalSitesResponse> {
return requestJson<DisposalSitesResponse>(`/projects/${projectId}/route/disposal-sites`, {
method: "POST",
body: JSON.stringify({ route_id: routeId, option, ...volumes }),
});
}
@@ -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
+76 -1
View File
@@ -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:
@@ -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:
+45
View File
@@ -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
+17
View File
@@ -594,6 +594,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
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<void> {
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;
@@ -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<string, unknown> | 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<void> {
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,
})),
};
},
};
}
@@ -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<string, unknown> | 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<string>(
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;
},
};
}
+73 -26
View File
@@ -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<void> {
@@ -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;
}
@@ -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;
}
@@ -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 {
@@ -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<void> {
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,
};
@@ -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");
}
+14
View File
@@ -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;
}
+59
View File
@@ -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<string, [number, number]>,
/** B05가 고른 사토장·토취장 — 정식 곡선 기준으로 운반거리를 다시 잰 형태로 받는다. */
disposalSites?: Record<string, unknown>,
): Record<string, unknown> {
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: {
+126
View File
@@ -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<string, string> = {
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<string, unknown> | undefined {
const sites = resolveDisposalSites(snapshot, plan);
if (!sites.length) return undefined;
return { option: snapshot?.option, sites };
}
+14
View File
@@ -508,6 +508,11 @@ export function createMassHaulLegend(
visibleKeys: ReadonlySet<string>,
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");
+40
View File
@@ -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)
#