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:
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
#
|
||||
|
||||
Reference in New Issue
Block a user