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