feat(B05/B06): 유토곡선 횡단 기준 통일·자동선정 삭제·계획선 R 연결 외 9건

사용자 피드백 반영(2026-08-03 2차).

유토곡선 일원화
- B05 유토곡선을 B06과 같은 계산(computeMassHaulSeries: 횡단 정식 + 종단 개략
  비교)으로 전환. 상세 조회가 미지정 측점에 기본 설계를 즉석 계산해 얹으므로
  B05 진입 시점에 이미 정식 곡선 재료가 있다. 표시 토글 sessionStorage 키와
  balloon 위치 scope를 B06과 공유해 두 화면이 같은 그림을 유지한다.
- B05 전용 개략 엔진(computeLongitudinalMassHaul) 삭제.
- 유토곡선 펼침 시 종단 그래프:곡선 세로 1:1 분할.

사토장·토취장 자동 선정 삭제
- 임도에는 토취장이 없고 부족분은 설계자가 계획선을 고쳐 맞춘다는 실무 판단에
  따라 4옵션(비용/지형안정성/계곡부/임내공간) 엔진·API·UI·config·저장 훅 전부
  제거. massHaulPayload/createMassHaulLegend 시그니처 원복.

기본 지반 변경
- 기본 설계 프리뷰·확정 기본값을 토사에서 리핑암 + 예상 암반 경계 0.5m로 변경.
  산지 절토는 표토 아래 암이 일반적이라 전량 토사 가정은 물량이 낙관적이다.
  발파암은 B06에서 측점별 수정.

계획선 R·선 연결 (배관 구조물 자리)
- 계획선 샘플이 고정 격자로만 평가되어 격자 사이 변화점(배관 측점 승격분)의
  모서리를 잘라먹던 결함 수정 — 샘플 집합에 PVI·BVC·EVC를 합집합으로 포함
  (프론트 buildAlignment + 서버 build_alignment 동일 규칙). 면적 가중치도
  합쳐진 격자로 재계산. 37.3m 변화점 + R=150 수치 검증 통과.

표시 개선
- EP(종점) 잔량 라벨: 곡선 끝점에 "EP {누가토량}㎥" 불투명 판(B05·B06 공통).
- 유토곡선 0선을 붉은 굵은 실선(2.5px)으로, 0 눈금값도 적색.
- 3D 뷰 [측점 가로선] 우측 [측점 라벨] 토글 신설(기본 꺼짐) — 구조물 측점만
  "측점번호 구조물명" 스프라이트 표시.

typecheck·vite build·ruff·B03 테스트 통과. 실서버 스모크로 리핑암 기본 프리뷰와
disposal-sites 제거 확인.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 19:54:37 +09:00
co-authored by Claude Opus 5
parent 6c90c26eef
commit f4f56069b0
25 changed files with 286 additions and 1534 deletions
-66
View File
@@ -5,7 +5,6 @@
* 백엔드 계약 (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에서 참조 (하드코딩 금지).
@@ -243,8 +242,6 @@ 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;
}
/** 프로젝트의 최신 경로를 확정한다. 비정규 측점이 있으면 그 횡단까지 생성한다. */
@@ -300,66 +297,3 @@ 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 }),
});
}
@@ -1,432 +0,0 @@
"""사토장·토취장 후보지 자동 선정 엔진 (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
@@ -364,9 +364,20 @@ def build_alignment(
pvi_s, pvi_z, sources = resolve_pvi(base_s, base_z, station_offsets)
curves, warnings = build_curves(pvi_s, pvi_z, policy, curve_radii)
segments = _segments(pvi_s, pvi_z)
plan = evaluate(pvi_s, pvi_z, curves, chainage)
difference = plan - ground
weights = _trapezoid_weights(chainage)
# 샘플 격자에 변화점(PVI)과 종단곡선 시·종점(BVC/EVC)을 합쳐서 평가한다. 격자만 쓰면
# 격자 사이에 놓인 변화점(배관 구조물 자리처럼 임의 chainage에 승격된 점)의 모서리를
# 정본 계획선이 잘라먹어, 종단곡선 R이 보이지 않고 B06 계획고·B07 CAD 계획선이 그
# 지점에서 어긋난다(2026-08-03 사용자 보고). 프론트(buildAlignment)와 같은 규칙이다.
extra_points = [pvi_s] + [
np.array([curve["bvc_m"], curve["chainage_m"], curve["evc_m"]]) for curve in curves
]
merged = np.concatenate([chainage, *extra_points])
merged = merged[(merged >= chainage[0] - 1e-6) & (merged <= chainage[-1] + 1e-6)]
sample_s = np.unique(np.round(merged, 6))
sample_ground = np.interp(sample_s, chainage, ground)
plan = evaluate(pvi_s, pvi_z, curves, sample_s)
difference = plan - sample_ground
weights = _trapezoid_weights(sample_s)
cut_area = float(weights[difference < 0] @ -difference[difference < 0])
fill_area = float(weights[difference > 0] @ difference[difference > 0])
@@ -451,12 +462,12 @@ def build_alignment(
"stations": _station_rows(stations, pvi_s, pvi_z, curves, chainage, ground),
"samples": [
{
"chainage_m": round(float(chainage[index]), 6),
"chainage_m": round(float(sample_s[index]), 6),
"elevation_m": round(float(plan[index]), 6),
"ground_elevation_m": round(float(ground[index]), 6),
"ground_elevation_m": round(float(sample_ground[index]), 6),
"difference_m": round(float(difference[index]), 6),
}
for index in range(len(chainage))
for index in range(len(sample_s))
],
"balance": {
"cut_area_m2": round(cut_area, 6),
+1 -76
View File
@@ -14,10 +14,9 @@ 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 _load_route_polyline, run_section_generation
from B05_wf2_Route.B05_wf2_Route_Engine_Sections import 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,
@@ -31,15 +30,12 @@ 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,
@@ -467,60 +463,6 @@ 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 입력 스냅샷을 반환한다."""
@@ -582,23 +524,6 @@ 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,29 +72,6 @@ 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,9 +258,6 @@ 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)
@@ -292,45 +289,3 @@ 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
+56 -1
View File
@@ -33,6 +33,12 @@ export interface SectionStationMarker {
/** 상단측(등고 높은 쪽) — 램프 컬러 표시용. Page가 사용자 변경분을 반영해 넘긴다. */
uphill_side?: "left" | "right" | null;
frame: { left_xy: [number, number] };
/** 측점 종류 — 구조물(비정규) 측점만 3D 라벨을 단다(2026-08-03 사용자 지시). */
kind?: string;
/** 측점번호 표기(`4+0.0`). 라벨 본문에 그대로 쓴다. */
label?: string;
/** 구조물 이름(배관 등). 있으면 측점번호 뒤에 붙인다. */
structure?: string;
}
// 측점 바 양 끝 원형 램프 색: 상단(등고 높은 쪽) 예상측=주황, 반대측=회색.
@@ -81,7 +87,10 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou
const markerGroup = new THREE.Group();
const routeGroup = new THREE.Group();
const stationGroup = new THREE.Group();
interactionGroup.add(markerGroup, stationGroup);
// 구조물 측점 라벨(스프라이트) 전용 — 측점선과 별개 토글이라 그룹을 나눈다.
const stationLabelGroup = new THREE.Group();
stationLabelGroup.visible = false;
interactionGroup.add(markerGroup, stationGroup, stationLabelGroup);
scene.add(interactionGroup, routeGroup);
let points = emptyPoints();
let selectedId: string | null = null;
@@ -240,8 +249,43 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou
});
}
/**
* 측점 라벨 스프라이트. 카메라를 항상 바라보는 캔버스 텍스처라 줌·회전과 무관하게 읽힌다.
* 라벨은 **구조물(비정규) 측점에만** 단다 — 전 측점에 달면 글자가 겹쳐 도면을 못 읽는다.
*/
function stationLabelSprite(text: string, position: THREE.Vector3): THREE.Sprite {
const fontPx = 28;
const padPx = 10;
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
if (context) {
context.font = `600 ${fontPx}px sans-serif`;
canvas.width = Math.ceil(context.measureText(text).width) + padPx * 2;
canvas.height = fontPx + padPx * 2;
// 캔버스 크기를 바꾸면 컨텍스트가 초기화되므로 폰트를 다시 지정해야 한다.
context.font = `600 ${fontPx}px sans-serif`;
context.fillStyle = "rgba(15, 23, 42, 0.72)";
context.fillRect(0, 0, canvas.width, canvas.height);
context.fillStyle = "#fef3c7";
context.textBaseline = "middle";
context.fillText(text, padPx, canvas.height / 2);
}
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
const sprite = new THREE.Sprite(
new THREE.SpriteMaterial({ map: texture, depthTest: false, transparent: true }),
);
const heightUnits = 3.2;
sprite.scale.set((canvas.width / canvas.height) * heightUnits, heightUnits, 1);
sprite.position.copy(position);
sprite.position.y += 3.4;
sprite.renderOrder = 10;
return sprite;
}
function renderStationLines(stations: SectionStationMarker[], halfWidth: number): void {
disposeGroup(stationGroup);
disposeGroup(stationLabelGroup);
const bounds = getBounds();
if (!bounds || halfWidth <= 0) return;
stations.forEach((station) => {
@@ -269,6 +313,14 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou
// 측점 바 양 끝 원형 램프: 상단(등고 높은 쪽) 예상측 컬러, 반대측 회색.
// 클릭하면 그 측을 상단측(=측구 방향)으로 지정한다(onUphillPick).
// 구조물 측점 라벨: `측점번호 구조물명`. 일반 측점은 라벨을 달지 않는다.
if (station.kind === "irregular" && station.label) {
const text = station.structure?.trim()
? `${station.label} ${station.structure.trim()}`
: station.label;
stationLabelGroup.add(stationLabelSprite(text, modelToScene(center, bounds)));
}
(["left", "right"] as const).forEach((side, endIndex) => {
const active = station.uphill_side === side;
const lamp = new THREE.Mesh(
@@ -352,6 +404,9 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou
setStationLinesVisible(visible: boolean) {
stationGroup.visible = visible;
},
setStationLabelsVisible(visible: boolean) {
stationLabelGroup.visible = visible;
},
onChange(listener: (next: RouteDesignPoints) => void) {
changeListener = listener;
},
+10 -17
View File
@@ -168,6 +168,8 @@ function interpolateIrregularStations(
chainage_m: entry.chainage_m,
label: irregularLabel(entry),
kind: "irregular" as const,
// 3D 측점 라벨이 `측점번호 구조물명`으로 표기할 수 있게 구조물 이름을 실어 보낸다.
structure: entry.structure,
}));
}
@@ -301,6 +303,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
onContoursVisible: viewer.setContoursVisible,
onAxesVisible: viewer.setAxesVisible,
onStationLinesVisible: viewer.setStationLinesVisible,
onStationLabelsVisible: viewer.setStationLabelsVisible,
onView: viewer.setView,
onResetView: () => viewer.setView("top"),
onMovePoint: viewer.beginMoveSelected,
@@ -594,8 +597,6 @@ 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");
@@ -657,21 +658,13 @@ 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,
);
// 유토곡선용 설정 — 토량환산계수·운반장비 경계·자연방토 판정 경사를 config_system
// 정의 그대로 받아 B06과 같은 계산을 태운다(프론트 사본 금지).
profilePanel.setEarthworkContext({
conversion: sectionContext.earthwork_conversion,
haulLimits: sectionContext.haul_equipment_limits,
naturalSpoilMinSlope: sectionContext.natural_spoil_min_ground_slope ?? undefined,
});
restorePanel(latestResponse);
renderLatest(latestResponse);
latest = latestResponse;
+3
View File
@@ -52,6 +52,8 @@ interface PanelCallbacks {
onContoursVisible: (visible: boolean) => void;
onAxesVisible: (visible: boolean) => void;
onStationLinesVisible: (visible: boolean) => void;
/** 구조물 측점 번호·이름 라벨 표시 토글(기본 꺼짐). */
onStationLabelsVisible: (visible: boolean) => void;
onView: (view: "iso" | "top" | "front" | "side") => void;
onResetView: () => void;
onMovePoint: () => void;
@@ -159,6 +161,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
toggleButton("등고선", true, callbacks.onContoursVisible),
toggleButton("축 표시", false, callbacks.onAxesVisible),
toggleButton(L("B05_Route_Field_StationLines"), true, callbacks.onStationLinesVisible),
toggleButton(L("B05_Route_Field_StationLabels"), false, callbacks.onStationLabelsVisible),
);
const separator1 = document.createElement("span");
separator1.className = "b05-route__view-separator";
@@ -321,17 +321,37 @@ export function buildAlignment(base: AlignmentBase, input: AlignmentEdits): Prof
curve.evc_elevation_m = evaluateAt(pviS, pviZ, curves, curve.evc_m);
});
const samples: AlignmentSample[] = base.chainage.map((chainage, index) => {
const plan = evaluateAt(pviS, pviZ, curves, chainage);
return {
chainage_m: chainage,
elevation_m: plan,
ground_elevation_m: base.ground[index],
difference_m: plan - base.ground[index],
};
});
// 샘플 격자에 변화점(PVI)과 종단곡선 시·종점(BVC/EVC)을 **합쳐서** 그린다.
// 격자만 쓰면 격자 사이에 놓인 변화점(배관 구조물 자리처럼 임의 chainage에 승격된 점)의
// 모서리를 선이 잘라먹어, 종단곡선 R이 안 보이고 편집 지점과 선이 어긋나 끊긴 것처럼
// 보인다(2026-08-03 사용자 보고). 곡선 구간 끝점까지 넣어야 직선→곡선 이음이 정확히 붙는다.
const sampleChainages = new Set<number>(base.chainage.map((value) => Number(value.toFixed(3))));
const first = base.chainage[0];
const last = base.chainage[base.chainage.length - 1];
for (const extra of [
...pviS,
...curves.flatMap((curve) => [curve.bvc_m, curve.chainage_m, curve.evc_m]),
]) {
if (extra >= first - 1e-6 && extra <= last + 1e-6) {
sampleChainages.add(Number(extra.toFixed(3)));
}
}
const samples: AlignmentSample[] = [...sampleChainages]
.sort((a, b) => a - b)
.map((chainage) => {
const plan = evaluateAt(pviS, pviZ, curves, chainage);
const ground = interpolate(base.chainage, base.ground, chainage);
return {
chainage_m: chainage,
elevation_m: plan,
ground_elevation_m: ground,
difference_m: plan - ground,
};
});
const weights = trapezoidWeights(base.chainage);
// 면적 가중치도 **합쳐진 샘플 격자**로 계산해야 한다 — base.chainage로 두면 추가된
// 변화점 샘플과 인덱스가 어긋나 절·성토 면적이 통째로 틀어진다.
const weights = trapezoidWeights(samples.map((sample) => sample.chainage_m));
let cutArea = 0;
let fillArea = 0;
samples.forEach((sample, index) => {
@@ -1,368 +0,0 @@
/* =============================================================================
* 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,
})),
};
},
};
}
@@ -1,26 +1,40 @@
/* =============================================================================
* B05_wf2_Route_UI_Profile_MassHaul.ts
* B05 계획 유토곡선 — 종단면 패널 안에 다시 접히는 2차 하단 슬라이드 패널.
* B05 유토곡선 — 종단면 패널 안에 다시 접히는 2차 하단 슬라이드 패널.
*
* ── 왜 B05에 유토곡선이 있는가 ───────────────────────────────────────
* 절·성토 균형은 [임도의 설계 및 시설기준] 2.다.(3)(나)가 **종단 시공계획고**를 대상으로
* 정한다. 사토장·토취장을 어디에 둘지도 계획고를 다시 끌어야 정리되는 문제라, 계획선
* 편집 정본·배수유역·배관 데이터가 모두 모여 있는 이 화면에서 곡선을 보며 잡는 것이 맞다.
* B06의 횡단 기준 곡선은 실측 단면적으로 낸 **정식 물량**이고, 이쪽은 계획용 개략값이다.
* ── B06과 같은 그림이어야 한다 (2026-08-03 사용자 확정) ──────────────
* 이 프로그램은 파일 입력 시 기본 설정값으로 B06 몫까지 미리 계산해 둔다 — 종횡단 상세
* 조회(`fetchSectionDetail`)가 지정 설계 없는 측점에 기본 설계를 즉석 계산해 얹으므로
* (`_attach_default_designs`), B05 진입 시점에 이미 **횡단 단면적 기반 정식 곡선**을 그릴
* 수 있다. 그래서 B06과 똑같이 `computeMassHaulSeries`(횡단 기준 + 종단 개략 비교)를 쓰고,
* 범례·토량 분배·balloon·요약도 전부 공용 모듈 그대로 쓴다.
*
* 표시 토글 sessionStorage 키도 B06과 **공유**한다 — 두 화면은 같은 그림의 두 창이라
* 한쪽에서 켠 곡선이 다른 쪽에서도 켜져 있어야 한다. 데이터 자체도 같은 상세 API와
* 같은 영구저장소(`longitudinal_sections.data.mass_haul`)를 읽으므로, B06에서 횡단을
* 고쳐 임시저장/확정하면 B05는 재조회만으로 같은 곡선을 받는다.
*
* ── 배치 ─────────────────────────────────────────────────────────
* 펼치면 12행 도면 테이블 자리를 그대로 덮는다(2026-08-03 사용자 지시). 곡선 SVG를 종단
* 그래프와 **같은 가로 스크롤러(canvas)** 안 형제로 넣어 X축이 저절로 맞물리게 하고,
* 범례·요약처럼 폭에 관여하면 안 되는 것만 스크롤러 밖 막대에 둔다 — B06에서 겪은
* "감싸는 상자 하나가 스크롤 폭 계산에 끼어들어 측점선이 어긋나는" 문제를 되풀이하지 않는다.
* 펼치면 12행 도면 테이블 자리를 덮고, 종단 그래프와 세로 1:1로 나눈다(2026-08-03 지시).
* 곡선 SVG는 종단 그래프와 **같은 가로 스크롤러(canvas)** 안 형제로 넣어 X축이 저절로
* 맞물리게 하고, 범례·요약처럼 폭에 관여하면 안 되는 것만 스크롤러 밖 막대에 둔다.
* ========================================================================== */
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 {
EarthworkConversion,
HaulEquipmentLimit,
MassHaulLongitudinal,
MassHaulSection,
MassHaulStationSource,
} from "@util/common_util_mass_haul_types";
import type { 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 {
computeMassHaulSeries,
MASS_HAUL_BALANCE_KEY,
MASS_HAUL_DEFAULT_VISIBLE,
} 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 {
@@ -30,32 +44,34 @@ import {
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;
/**
* 표시 곡선·레이어 키 — **B06 유토곡선과 같은 키**를 쓴다. 두 화면은 같은 그림의 두 창이라
* 표시 상태가 갈리면 "같은 곡선인데 왜 다르게 보이나"가 된다(2026-08-03 사용자 확정).
* 정의처는 B06 `_UI_Section_View`와 이 파일 두 곳뿐이며 값이 반드시 같아야 한다.
*/
const VISIBLE_KEY = "b06:masshaul-visible-v4";
const DEFAULT_VISIBLE: string[] = [...MASS_HAUL_DEFAULT_VISIBLE, MASS_HAUL_BALANCE_KEY];
/** 유토곡선 계산에 필요한 프로젝트 설정 — B05 Page가 `fetchSectionContext()`에서 받아 넘긴다. */
export interface RouteMassHaulContext {
conversion: EarthworkConversion;
haulLimits?: HaulEquipmentLimit[];
/** 표준횡단 설정의 노반폭(`road_width_m + shoulder_left_m + shoulder_right_m`). */
roadbedWidthM: number;
/** 자연방토 판정 경사(rise/run). 못 받으면 전부 불가(보수적) — B06과 같은 규약. */
naturalSpoilMinSlope?: number;
}
export interface RouteMassHaulDrawParams {
/** 측점선을 세울 목록 — 종단 그래프에 넣은 것과 **같은 배열**이어야 자리가 맞는다. */
longitudinal: MassHaulStationSource;
/** 편집이 반영된 현재 계획선(`toDesignProfile` 결과). */
designProfile: MassHaulProfile;
stationSource: MassHaulStationSource;
/** 종단 개략 곡선 입력 — 편집이 반영된 현재 계획선을 담은 종단 데이터. */
longitudinal: MassHaulLongitudinal;
/** 횡단 정식 곡선 입력 — 상세 조회가 내려준 측점별 설계(기본 프리뷰 포함). */
crossSections: MassHaulSection[];
/** 종단 그래프와 같은 X 매핑(누가거리 최댓값·좌우 여백). */
axis: MassHaulAxis;
stationInterval: number;
@@ -72,12 +88,6 @@ export interface RouteMassHaulPanel {
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을 돌려주고,
* 이때 호출한 쪽은 원래대로 도면 테이블을 그린다.
@@ -85,6 +95,17 @@ export interface RouteMassHaulPanel {
draw(params: RouteMassHaulDrawParams): SVGSVGElement | null;
}
function readVisible(): Set<string> {
try {
const raw = sessionStorage.getItem(VISIBLE_KEY);
if (!raw) return new Set(DEFAULT_VISIBLE);
const parsed = JSON.parse(raw) as unknown;
return Array.isArray(parsed) ? new Set(parsed.map(String)) : new Set(DEFAULT_VISIBLE);
} catch {
return new Set(DEFAULT_VISIBLE);
}
}
/**
* @param onChanged 펼침·범례 토글로 다시 그려야 할 때 호출된다(패널 전체 redraw).
*/
@@ -94,20 +115,14 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
handle.className = "b05-profile__masshaul-handle";
const caption = document.createElement("span");
caption.className = "b05-profile__masshaul-caption";
caption.textContent = "계획 유토곡선";
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;
@@ -124,6 +139,7 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
caption.addEventListener("click", () => applyOpen(!open));
function toggleSeries(key: string): void {
const visible = readVisible();
if (visible.has(key)) visible.delete(key);
else visible.add(key);
sessionStorage.setItem(VISIBLE_KEY, JSON.stringify([...visible]));
@@ -137,44 +153,36 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
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);
const note = (message: string): null => {
const empty = document.createElement("span");
empty.className = "b05-profile__masshaul-empty";
empty.textContent = message;
bar.append(empty);
return null;
}
const result = computeLongitudinalMassHaul(
{ length_m: 0, design_profiles: [params.designProfile] },
};
if (!context) return note("토량환산계수를 불러오지 못해 유토곡선을 계산할 수 없습니다.");
// B06과 같은 계산 — 횡단 기준(정식)과 종단 기준(개략 비교)을 함께 낸다.
const series: MassHaulSeries[] = computeMassHaulSeries(
params.longitudinal,
params.crossSections,
context.conversion,
{ roadbedWidthM: context.roadbedWidthM },
context.naturalSpoilMinSlope,
);
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;
if (!series.length) return note("횡단 설계가 아직 없어 유토곡선을 그릴 수 없습니다.");
const visible = readVisible();
// 토량 분배는 켜 둔 첫 곡선(정식 우선)에만 얹는다 — B06과 같은 규칙.
const banded = series.find((entry) => visible.has(entry.key));
const haulPlan: HaulPlan | null =
banded && visible.has(MASS_HAUL_BALANCE_KEY)
? computeHaulPlan(banded.result, context.haulLimits)
: null;
const chart = createMassHaulChart(
series,
visible,
params.longitudinal,
params.stationSource,
params.axis,
params.selectedStationId,
params.stationInterval,
@@ -185,21 +193,13 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
haulPlan,
);
bar.append(
createMassHaulLegend(
series,
visible,
toggleSeries,
() => {
resetBalloonOffsets();
onChanged();
},
disposal.toggles,
),
createMassHaulSummary(series[0], haulPlan),
disposal.panel,
createMassHaulLegend(series, visible, toggleSeries, () => {
resetBalloonOffsets();
onChanged();
}),
);
// 잔량(사토·토취 구간)이 바뀌었을 때만 서버를 다시 부른다 — 안에서 지문으로 거른다.
disposal.update(haulPlan);
const summarySeries = banded ?? series[0];
bar.append(createMassHaulSummary(summarySeries, haulPlan));
return chart;
},
};
+19 -12
View File
@@ -47,6 +47,7 @@ import {
toAlignmentBase,
} from "./B05_wf2_Route_UI_Profile_Alignment";
import { createEditOverlay, createProfileEditStore } from "./B05_wf2_Route_UI_Profile_Edit";
import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
import {
createRouteMassHaulPanel,
type RouteMassHaulContext,
@@ -440,9 +441,13 @@ export function createRouteProfilePanel(
fixedTableHeight = Math.round(available * (1 - CHART_HEIGHT_RATIO));
sessionStorage.setItem(TABLE_HEIGHT_KEY, String(fixedTableHeight));
}
const chartHeight = alignment
? Math.max(MIN_CHART_HEIGHT, available - fixedTableHeight)
: available;
// 유토곡선을 펼치면 종단 그래프와 곡선이 세로를 **1:1**로 나눈다(2026-08-03 사용자 지시)
// — 테이블용 고정 높이를 그대로 쓰면 곡선이 종단 그래프를 밀어낸다.
const chartHeight = massHaul.isOpen()
? Math.max(MIN_CHART_HEIGHT, Math.floor(available / 2))
: alignment
? Math.max(MIN_CHART_HEIGHT, available - fixedTableHeight)
: available;
const tableHeight = available - chartHeight;
// 계획 유토곡선을 펼치면 도면 테이블은 그리지 않는다 — 같은 자리를 나눠 쓰면 둘 다 뭉개진다.
@@ -559,8 +564,11 @@ export function createRouteProfilePanel(
const massHaulChart =
alignment && designProfiles[0]
? massHaul.draw({
longitudinal: graphLongitudinal,
designProfile: designProfiles[0],
stationSource: graphLongitudinal,
// 종단 개략 곡선은 편집이 반영된 현재 계획선을, 정식 곡선은 상세 조회가 내려준
// 측점별 횡단 설계(기본 프리뷰 포함)를 입력으로 쓴다 — B06과 같은 재료다.
longitudinal: { length_m: longitudinal.length_m, design_profiles: designProfiles },
crossSections: detail.cross_sections,
axis: {
maxChainageM: maxChainageOf(longitudinal),
// 종단 그래프의 `chainageMapper`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를
@@ -643,8 +651,12 @@ export function createRouteProfilePanel(
// 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지).
if (carried) store.replace(carried);
alignment = base ? buildAlignment(base, store.edits()) : null;
// 사토장·토취장 선정은 경로 단위 결과라 대상 경로가 바뀌면 다시 받아야 한다.
massHaul.setRoute(projectId, routeId);
// 유토곡선 balloon 위치 캐시 — B06과 **같은 scope**를 써서 두 화면이 같은 자리를 공유한다.
// 영구저장소 값(detail.balloon_offsets)이 있으면 그것이 이긴다(B06과 같은 규칙).
configureBalloonOffsets(
`${projectId}:${routeId ?? "-"}`,
nextDetail.balloon_offsets ?? undefined,
);
draw();
requestAnimationFrame(draw);
},
@@ -684,11 +696,6 @@ 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> {
@@ -1,121 +0,0 @@
/* =============================================================================
* 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;
}
+3
View File
@@ -47,6 +47,8 @@ export interface RouteViewer {
setContoursVisible: (visible: boolean) => void;
setAxesVisible: (visible: boolean) => void;
setStationLinesVisible: (visible: boolean) => void;
/** 구조물(비정규) 측점의 번호·이름 라벨 표시 토글. */
setStationLabelsVisible: (visible: boolean) => void;
renderStationLines: (stations: SectionStationMarker[], halfWidth: number) => void;
setView: (view: "iso" | "top" | "front" | "side") => void;
beginMoveSelected: () => void;
@@ -370,6 +372,7 @@ export function createRouteViewer(): RouteViewer {
axes.visible = visible;
},
setStationLinesVisible: markers.setStationLinesVisible,
setStationLabelsVisible: markers.setStationLabelsVisible,
renderStationLines: markers.renderStationLines,
setView: fit,
beginMoveSelected() {
@@ -15,7 +15,6 @@
* ========================================================================== */
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,
@@ -182,11 +181,6 @@ export interface LongitudinalSection {
* `B05_wf2_Route_UI_Profile_Alignment.ProfileAlignment`.
*/
profile_alignment?: unknown;
/**
* B05 ·( ).
* B06은 ** ** B05 .
*/
disposal_sites?: DisposalSitesSnapshot;
}
export interface CrossSection extends SectionStation {
@@ -459,10 +459,15 @@ def _default_section_modes(longitudinal: dict[str, Any]) -> dict[float, str]:
def _attach_default_designs(
longitudinal: dict[str, Any], cross_sections: list[dict[str, Any]]
) -> None:
"""지정 설계가 없는 횡단에 기본값(토사 + 상단측 절토) 프리뷰 설계를 즉석 계산해 얹는다.
"""지정 설계가 없는 횡단에 기본값(리핑암 + 암반 경계 0.5m + 상단측 절토) 프리뷰 얹는다.
detail 조회가 이미 읽어온 samples와 종단 계획선을 그대로 써서 추가 파일 I/O 없이
측점 프리뷰를 만든다(미저장). 계산 불가 측점은 건너뛴다.
기본 지반을 토사가 아니라 **리핑암 + 지표 아래 0.5m 암반 경계** 두는 이유(2026-08-03
사용자 확정): 산지 절토는 대부분 표토 아래에서 암이 나오므로, 전량 토사 가정은 물량이
낙관적으로 나온다. 지표 0.5m까지 토사· 아래 리핑암인 2 단면이 안전한 출발값이고,
발파암이 있으면 사용자가 B06에서 측점별로 고친다.
"""
default_modes = _default_section_modes(longitudinal)
pavement = _pavement_suggestions(longitudinal)
@@ -475,9 +480,10 @@ def _attach_default_designs(
design = compute_cross_design(
section.get("samples", []),
design_elevation_from_longitudinal(longitudinal, chainage_m),
ground_type="soil",
ground_type="ripping_rock",
section_mode=default_modes.get(round(chainage_m, 3), "left_cut"),
paved=suggested,
rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
)
design["status"] = "provisional"
design["pavement_suggested"] = suggested
@@ -492,7 +498,7 @@ def _compute_default_designs(
chainages: list[float],
standard: dict[str, Any] | None = None,
) -> list[tuple[float, dict[str, Any]]]:
"""미지정 측점들을 기본값(토사/좌절토)으로 계산한 (chainage, design) 목록을 만든다.
"""미지정 측점들을 기본값(리핑암 + 암반 경계 0.5m/상단측 절토)으로 계산한 목록을 만든다.
standard가 오면(확정 요청의 패널 편집값) 값으로 표준단면 기하를 계산한다.
계획고 부재 등으로 계산 불가한 측점은 조용히 건너뛴다(확정을 막지 않기 위함).
@@ -519,10 +525,11 @@ def _compute_default_designs(
design = compute_cross_design(
samples,
design_elevation_from_longitudinal(longitudinal, chainage_m),
ground_type="soil",
ground_type="ripping_rock",
section_mode=default_modes.get(round(chainage_m, 3), "left_cut"),
paved=suggested,
standard=standard,
rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
)
design["status"] = "provisional"
design["pavement_suggested"] = suggested
@@ -36,7 +36,6 @@ 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 {
@@ -416,16 +415,13 @@ 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,
haulPlan,
computeHaulPlan(result, context?.haul_equipment_limits),
balloonOffsetsPayload(),
// B05 선정 결과를 정식 곡선 기준 운반거리로 다시 재 실어 B08이 그대로 받게 한다.
disposalSitesPayload(sectionDetail?.longitudinal.disposal_sites, haulPlan),
)
: undefined,
};
@@ -43,7 +43,6 @@ import {
configureBalloonOffsets,
resetBalloonOffsets,
} from "@util/common_util_mass_haul_balance_view";
import { createDisposalSummary } from "@util/common_util_mass_haul_sites";
import {
createMassHaulChart,
createMassHaulLegend,
@@ -527,9 +526,6 @@ 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");
}
+21 -17
View File
@@ -95,16 +95,34 @@
/* 0선: 절토 우세와 성토 우세의 경계라 격자보다 진하게. 어떤 노선에서도 반드시 보인다.
**실선**이다(2026-08-02 사용자 지시) 도면에서 파선은 장비 경계현의 몫이라,
0선까지 파선이면 둘이 구분되지 않는다. */
/* 0선은 · 우세를 가르는 기준선 붉은 굵은 실선으로 다른 격자와 확실히 구분한다
(2026-08-03 사용자 지시). */
.b06-masshaul__zero {
stroke: var(--color-text-secondary);
stroke-width: 1.2;
stroke: var(--color-danger, #d32f2f);
stroke-width: 2.5;
}
.b06-masshaul__zero-tick {
fill: var(--color-text-secondary);
fill: var(--color-danger, #d32f2f);
font-weight: var(--font-weight-medium);
}
/* EP(종점) 잔량 라벨 — 곡선 위에 놓이므로 불투명 판을 깐다. */
.b06-masshaul__ep-dot {
fill: var(--color-danger, #d32f2f);
}
.b06-masshaul__ep-plate {
fill: var(--color-surface);
stroke: var(--color-border);
}
.b06-masshaul__ep-label {
fill: var(--color-text-body);
font-size: 11px;
font-weight: 600;
}
/* 그래프 이름표 배경 — 이름이 곡선 위에 놓이므로 불투명하게 깐다. */
.b06-masshaul__name-plate {
fill: var(--color-surface);
@@ -353,17 +371,3 @@
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,61 +392,6 @@ 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 ** ** .
@@ -493,16 +438,12 @@ 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
@@ -1,126 +0,0 @@
/* =============================================================================
* 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 };
}
+31 -14
View File
@@ -415,6 +415,37 @@ export function createMassHaulChart(
);
}
// EP(종점) 잔량 라벨 — 곡선 끝점의 누가토량이 곧 노선 전체의 잉여(+)/부족(−)이라
// 눈금을 되짚지 않아도 읽히게 값 자체를 적는다(2026-08-03 사용자 지시). 단위 ㎥ 명기.
if (banded) {
const last = banded.result.points[banded.result.points.length - 1];
const epX = x(last.chainage_m);
const epY = y(last.cumulative_volume_m3);
const text = `EP ${formatVolume(last.cumulative_volume_m3)}`;
const labelWidth = textWidth(text) + 10;
// 종점은 오른쪽 끝이라 라벨을 왼쪽으로 눕히고, 위아래는 플롯 안으로 클램프한다.
const labelX = Math.max(epX - 8, axis.padLeft + labelWidth);
const labelY = Math.min(Math.max(epY - 8, MASS_PAD_TOP + 12), heightPx - MASS_PAD_BOTTOM - 4);
svg.append(
svgElement("circle", { cx: epX, cy: epY, r: 3.5, class: "b06-masshaul__ep-dot" }),
svgElement("rect", {
x: labelX - labelWidth,
y: labelY - 12,
width: labelWidth,
height: 16,
rx: 4,
ry: 4,
class: "b06-masshaul__ep-plate",
}),
svgText(text, {
x: labelX - 5,
y: labelY,
"text-anchor": "end",
class: "b06-masshaul__ep-label",
}),
);
}
// 토량 분배 레이어 — 곡선 **위**, 선택 말풍선 **아래**. 순서를 바꾸면 평형선이 곡선을
// 가리거나(위로 올리면) 선택 말풍선이 balloon에 묻힌다(아래로 내리면).
if (haulPlan) {
@@ -508,11 +539,6 @@ 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";
@@ -544,15 +570,6 @@ 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,46 +481,6 @@ 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)
#
+1
View File
@@ -141,6 +141,7 @@ export const ui_locales_b2 = {
B05_Route_Field_CrossSample: ["횡단 샘플 간격(m)", "Cross sample interval (m)"],
B05_Route_Field_LongSample: ["종단 샘플 간격(m)", "Long sample interval (m)"],
B05_Route_Field_StationLines: ["측점 가로선", "Station cross lines"],
B05_Route_Field_StationLabels: ["측점 라벨", "Station labels"],
/* --- B06_wf3_ProfileCross 종·횡단 생성 --- */
B06_Profile_Title: ["횡단설계", "Cross Design"],