260722_B05_종단 설계 반영 초안
This commit is contained in:
@@ -187,6 +187,39 @@ export async function updateContourInterval(
|
||||
);
|
||||
}
|
||||
|
||||
/** 종단 계획선 편집 델타 (자동 선형 대비 측점 계획고 델타 + 종단곡선 길이). */
|
||||
export interface ProfileAlignmentEdits {
|
||||
station_offsets: Record<string, number>;
|
||||
curve_lengths: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface ProfileAlignmentSaveResponse {
|
||||
status: string;
|
||||
project_id: string;
|
||||
route_id: number;
|
||||
profile_alignment: unknown;
|
||||
grade_summary: RouteGradeSummary | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 종단 계획선 사용자 편집을 영속화한다.
|
||||
* 화면은 즉시 계산해 보여주고, 여기서 **편집 델타만** 보내면 서버가 저장된 자동
|
||||
* 선형에 다시 얹어 정본(longitudinal.json)을 만든다.
|
||||
*/
|
||||
export async function saveProfileAlignment(
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
edits: ProfileAlignmentEdits,
|
||||
): Promise<ProfileAlignmentSaveResponse> {
|
||||
return requestJson<ProfileAlignmentSaveResponse>(
|
||||
`/projects/${projectId}/route/profile-alignment`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ route_id: routeId, ...edits }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 프로젝트의 최신 경로를 확정한다. */
|
||||
export async function confirmRoute(projectId: string): Promise<RouteConfirmResponse> {
|
||||
return requestJson<RouteConfirmResponse>(`/projects/${projectId}/route/confirm`, {
|
||||
|
||||
@@ -197,7 +197,7 @@ def resolve_grade_options(
|
||||
)
|
||||
|
||||
|
||||
def _ground_profile(longitudinal: dict[str, Any]) -> tuple[np.ndarray, np.ndarray]:
|
||||
def ground_profile(longitudinal: dict[str, Any]) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""종단 샘플에서 (chainage, 지반고) 배열을 만든다. 결측은 선형 보간한다."""
|
||||
samples = longitudinal.get("samples") or []
|
||||
chainage = np.array([float(s.get("chainage_m", 0.0)) for s in samples], dtype=np.float64)
|
||||
@@ -219,7 +219,7 @@ def _ground_profile(longitudinal: dict[str, Any]) -> tuple[np.ndarray, np.ndarra
|
||||
return chainage, ground
|
||||
|
||||
|
||||
def _detect_main_direction(ground: np.ndarray, rise: float) -> tuple[str, str | None]:
|
||||
def detect_main_direction(ground: np.ndarray, rise: float) -> tuple[str, str | None]:
|
||||
"""지반 종단 형상에서 역기울기 판정 기준이 되는 주 진행방향을 정한다.
|
||||
|
||||
시·종점 고도차만으로 부호를 보면 V자(계곡 횡단)·Λ자(능선 통과) 노선에서
|
||||
@@ -386,7 +386,7 @@ def design_grade_line(longitudinal: dict[str, Any], options: GradeDesignOptions)
|
||||
반환 dict는 longitudinal.json의 `design_profiles` 배열에 그대로 넣는다.
|
||||
"""
|
||||
options.validate()
|
||||
chainage, ground = _ground_profile(longitudinal)
|
||||
chainage, ground = ground_profile(longitudinal)
|
||||
total = float(chainage[-1])
|
||||
if total <= 0:
|
||||
raise ValueError("종단 연장이 0이어서 계획선을 만들 수 없습니다.")
|
||||
@@ -400,7 +400,7 @@ def design_grade_line(longitudinal: dict[str, Any], options: GradeDesignOptions)
|
||||
rise = fixed[1] - fixed[0]
|
||||
warnings = list(options.warnings)
|
||||
direction, direction_note = (
|
||||
_detect_main_direction(ground, rise)
|
||||
detect_main_direction(ground, rise)
|
||||
if options.main_direction == "auto"
|
||||
else (options.main_direction, None)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
"""B05 종단 계획선 선형(직선 + 측점 위 종단곡선) 파생 모듈.
|
||||
|
||||
계획선을 "폴리라인 샘플"이 아니라 **변화점(PVI) 구조**로 1차 표현하고, 도면
|
||||
테이블(구배·절토고·성토고·계획고·지반고·누가거리·거리·측점·곡선)에 필요한 값을
|
||||
전부 여기서 파생시킨다. 폴리라인 샘플은 이 구조에서 만들어지므로 B06/B07은
|
||||
기존 `design_profiles[].samples` 계약을 그대로 쓴다.
|
||||
|
||||
기하 규칙 (사용자 확정 사항):
|
||||
- 변화점은 **기준 측점 위에만** 놓인다 → 곡선 중심이 측점 수직선상에 있다.
|
||||
- 종단곡선은 변화점 대칭 배치이며 좌우에 직선 구간이 반드시 남는다.
|
||||
- 곡선 기본 길이 L = 측점간격 × `curve_length_ratio`, 반경 R = L / |A| 로 파생.
|
||||
|
||||
수치 최적화(직선 분할 DP·표고 결정)는 [[B05_wf2_Route_Engine_Grade_Solver]]가,
|
||||
기준 해석과 오케스트레이션은 [[B05_wf2_Route_Engine_Grade]]가 담당한다. 이 모듈은
|
||||
scipy에 의존하지 않는 순수 기하 계산만 두어 프론트엔드 구현과 1:1로 대응시킨다.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from config.config_system import FOREST_ROAD_PROFILE_ALIGNMENT
|
||||
|
||||
ALIGNMENT_SCHEMA_VERSION = 1
|
||||
# chainage를 dict 키로 쓸 때의 표기. 프론트엔드(`toFixed(3)`)와 반드시 같아야 한다.
|
||||
_KEY_DECIMALS = 3
|
||||
|
||||
|
||||
def chainage_key(value: float) -> str:
|
||||
"""chainage를 편집 델타 dict의 키 문자열로 바꾼다(프론트와 동일 규칙)."""
|
||||
return f"{float(value):.{_KEY_DECIMALS}f}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AlignmentPolicy:
|
||||
"""계획선 선형·편집 정책. config 기본값 위에 노선별 해석값을 얹는다."""
|
||||
|
||||
station_interval_m: float
|
||||
curve_length_ratio: float
|
||||
curve_length_min_m: float
|
||||
curve_tangent_max_ratio: float
|
||||
curve_skip_legal_exception: bool
|
||||
balance_tolerance_percent: float
|
||||
pvi_penalty_m2: float
|
||||
min_segment_stations: int
|
||||
edit_step_m: float
|
||||
grade_violation_policy: str
|
||||
max_grade_pct: float
|
||||
curve_skip_delta_pct: float
|
||||
paved: bool
|
||||
|
||||
@classmethod
|
||||
def from_config(
|
||||
cls,
|
||||
*,
|
||||
station_interval_m: float,
|
||||
max_grade_pct: float,
|
||||
curve_skip_delta_pct: float,
|
||||
paved: bool,
|
||||
) -> "AlignmentPolicy":
|
||||
config = FOREST_ROAD_PROFILE_ALIGNMENT
|
||||
return cls(
|
||||
station_interval_m=float(station_interval_m),
|
||||
curve_length_ratio=float(config["curve_length_ratio"]),
|
||||
curve_length_min_m=float(config["curve_length_min_m"]),
|
||||
curve_tangent_max_ratio=float(config["curve_tangent_max_ratio"]),
|
||||
curve_skip_legal_exception=bool(config["curve_skip_legal_exception"]),
|
||||
balance_tolerance_percent=float(config["balance_tolerance_percent"]),
|
||||
pvi_penalty_m2=float(config["pvi_penalty_m2"]),
|
||||
min_segment_stations=int(config["min_segment_stations"]),
|
||||
edit_step_m=float(config["edit_step_m"]),
|
||||
grade_violation_policy=str(config["grade_violation_policy"]),
|
||||
max_grade_pct=float(max_grade_pct),
|
||||
curve_skip_delta_pct=float(curve_skip_delta_pct),
|
||||
paved=bool(paved),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: dict[str, Any]) -> "AlignmentPolicy":
|
||||
"""저장된 정책 스냅샷을 복원한다(편집 저장 시 기준을 흔들지 않기 위함)."""
|
||||
config = FOREST_ROAD_PROFILE_ALIGNMENT
|
||||
return cls(
|
||||
station_interval_m=float(payload.get("station_interval_m") or 20.0),
|
||||
curve_length_ratio=float(
|
||||
payload.get("curve_length_ratio", config["curve_length_ratio"])
|
||||
),
|
||||
curve_length_min_m=float(
|
||||
payload.get("curve_length_min_m", config["curve_length_min_m"])
|
||||
),
|
||||
curve_tangent_max_ratio=float(
|
||||
payload.get("curve_tangent_max_ratio", config["curve_tangent_max_ratio"])
|
||||
),
|
||||
curve_skip_legal_exception=bool(
|
||||
payload.get("curve_skip_legal_exception", config["curve_skip_legal_exception"])
|
||||
),
|
||||
balance_tolerance_percent=float(
|
||||
payload.get("balance_tolerance_percent", config["balance_tolerance_percent"])
|
||||
),
|
||||
pvi_penalty_m2=float(config["pvi_penalty_m2"]),
|
||||
min_segment_stations=int(config["min_segment_stations"]),
|
||||
edit_step_m=float(payload.get("edit_step_m", config["edit_step_m"])),
|
||||
grade_violation_policy=str(
|
||||
payload.get("grade_violation_policy", config["grade_violation_policy"])
|
||||
),
|
||||
max_grade_pct=float(payload.get("max_grade_pct") or 9.0),
|
||||
curve_skip_delta_pct=float(payload.get("curve_skip_delta_pct") or 5.0),
|
||||
paved=bool(payload.get("paved", False)),
|
||||
)
|
||||
|
||||
@property
|
||||
def default_curve_length_m(self) -> float:
|
||||
"""측점간격 기준 기본 종단곡선 길이."""
|
||||
return max(self.curve_length_min_m, self.station_interval_m * self.curve_length_ratio)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""프론트엔드가 동일 기하를 재현하는 데 필요한 상수 묶음."""
|
||||
return {
|
||||
"station_interval_m": self.station_interval_m,
|
||||
"curve_length_ratio": self.curve_length_ratio,
|
||||
"curve_length_min_m": self.curve_length_min_m,
|
||||
"curve_tangent_max_ratio": self.curve_tangent_max_ratio,
|
||||
"curve_skip_legal_exception": self.curve_skip_legal_exception,
|
||||
"default_curve_length_m": self.default_curve_length_m,
|
||||
"balance_tolerance_percent": self.balance_tolerance_percent,
|
||||
"edit_step_m": self.edit_step_m,
|
||||
"grade_violation_policy": self.grade_violation_policy,
|
||||
"max_grade_pct": self.max_grade_pct,
|
||||
"curve_skip_delta_pct": self.curve_skip_delta_pct,
|
||||
"paved": self.paved,
|
||||
}
|
||||
|
||||
|
||||
def tangent_elevation(pvi_s: np.ndarray, pvi_z: np.ndarray, targets: np.ndarray) -> np.ndarray:
|
||||
"""종단곡선을 빼고 변화점 직선만으로 본 표고(탄젠트 표고)."""
|
||||
return np.interp(targets, pvi_s, pvi_z)
|
||||
|
||||
|
||||
def resolve_pvi(
|
||||
base_s: np.ndarray,
|
||||
base_z: np.ndarray,
|
||||
station_offsets: dict[str, float],
|
||||
) -> tuple[np.ndarray, np.ndarray, list[str]]:
|
||||
"""자동 변화점에 사용자 편집 측점을 합쳐 최종 변화점 집합을 만든다.
|
||||
|
||||
편집 델타는 **자동 선형의 탄젠트 표고 기준**으로 해석한다. 이렇게 해야 다른
|
||||
측점을 나중에 건드려도 이미 편집한 측점의 표고가 흔들리지 않고, 키를 지우면
|
||||
자동 선형으로 정확히 돌아온다(원복).
|
||||
"""
|
||||
nodes: dict[float, float] = {
|
||||
round(float(s), _KEY_DECIMALS): float(z) for s, z in zip(base_s, base_z)
|
||||
}
|
||||
sources: dict[float, str] = {key: "auto" for key in nodes}
|
||||
for key, offset in (station_offsets or {}).items():
|
||||
try:
|
||||
chainage = round(float(key), _KEY_DECIMALS)
|
||||
delta = float(offset)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not np.isfinite(chainage) or not np.isfinite(delta):
|
||||
continue
|
||||
if chainage < float(base_s[0]) - 1e-6 or chainage > float(base_s[-1]) + 1e-6:
|
||||
continue
|
||||
base_value = float(np.interp(chainage, base_s, base_z))
|
||||
nodes[chainage] = base_value + delta
|
||||
sources[chainage] = "user"
|
||||
ordered = sorted(nodes.items())
|
||||
pvi_s = np.array([item[0] for item in ordered], dtype=np.float64)
|
||||
pvi_z = np.array([item[1] for item in ordered], dtype=np.float64)
|
||||
return pvi_s, pvi_z, [sources[value] for value in pvi_s.tolist()]
|
||||
|
||||
|
||||
def build_curves(
|
||||
pvi_s: np.ndarray,
|
||||
pvi_z: np.ndarray,
|
||||
policy: AlignmentPolicy,
|
||||
curve_lengths: dict[str, float] | None = None,
|
||||
) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
"""각 변화점에 대칭 종단곡선을 삽입하고 곡선 제원을 만든다.
|
||||
|
||||
곡선 반쪽 길이는 짧은 쪽 인접 직선의 `curve_tangent_max_ratio` 이내로 제한해
|
||||
좌우에 직선이 반드시 남게 한다(사용자가 R을 키워도 곡선끼리 겹치지 않는다).
|
||||
"""
|
||||
overrides = curve_lengths or {}
|
||||
spans = pvi_s[1:] - pvi_s[:-1]
|
||||
grades = (pvi_z[1:] - pvi_z[:-1]) / np.where(spans > 0, spans, 1.0)
|
||||
curves: list[dict[str, Any]] = []
|
||||
warnings: list[str] = []
|
||||
skip_delta = policy.curve_skip_delta_pct / 100.0
|
||||
for index in range(1, len(pvi_s) - 1):
|
||||
grade_in, grade_out = float(grades[index - 1]), float(grades[index])
|
||||
delta = grade_out - grade_in
|
||||
chainage = float(pvi_s[index])
|
||||
key = chainage_key(chainage)
|
||||
if abs(delta) < 1e-9:
|
||||
continue
|
||||
# 법정 다-(3)-(다)는 "종단곡선을 두지 않을 수 있다"는 허용 조항이다.
|
||||
# 실무 도면은 대수차가 작아도 변화점을 원곡선으로 처리하므로, 기본은 곡선을
|
||||
# 삽입하고 생략 가능 구간이라는 표시만 남긴다(config로 실제 생략 전환 가능).
|
||||
skip_allowed = not policy.paved and abs(delta) <= skip_delta + 1e-12
|
||||
omitted = skip_allowed and policy.curve_skip_legal_exception
|
||||
requested = overrides.get(key)
|
||||
try:
|
||||
length = float(requested) if requested is not None else policy.default_curve_length_m
|
||||
except (TypeError, ValueError):
|
||||
length = policy.default_curve_length_m
|
||||
if not np.isfinite(length) or length <= 0:
|
||||
length = policy.default_curve_length_m
|
||||
half_limit = (
|
||||
min(float(spans[index - 1]), float(spans[index])) * policy.curve_tangent_max_ratio
|
||||
)
|
||||
half = min(length / 2.0, half_limit)
|
||||
if half <= 1e-9:
|
||||
continue
|
||||
if length / 2.0 - half > 1e-6:
|
||||
warnings.append(
|
||||
f"chainage {chainage:.1f}m: 인접 직선이 짧아 종단곡선 길이를 "
|
||||
f"{half * 2.0:.1f}m로 줄였습니다."
|
||||
)
|
||||
length = half * 2.0
|
||||
curves.append(
|
||||
{
|
||||
"pvi_index": index,
|
||||
"chainage_m": chainage,
|
||||
"elevation_m": float(pvi_z[index]),
|
||||
"grade_in": grade_in,
|
||||
"grade_out": grade_out,
|
||||
"delta_pct": delta * 100.0,
|
||||
"bvc_m": chainage - half,
|
||||
"evc_m": chainage + half,
|
||||
"length_m": length,
|
||||
"radius_m": length / abs(delta),
|
||||
# K = 곡선길이 / 기울기 대수차(%) — 도면 주기 표기와 동일 정의
|
||||
"k": length / (abs(delta) * 100.0),
|
||||
# 중앙종거: 변화점에서 탄젠트와 곡선의 수직 거리 (|A|·L/8)
|
||||
"middle_ordinate_m": abs(delta) * length / 8.0,
|
||||
# 포물선 삽입으로 직선 폴리라인 대비 발생하는 면적 차 (A·L²/24)
|
||||
"area_offset_m2": delta * length * length / 24.0,
|
||||
"omitted": bool(omitted),
|
||||
"skip_allowed": bool(skip_allowed),
|
||||
"omit_reason": (
|
||||
f"비포장 대수차 {policy.curve_skip_delta_pct:.0f}% 이하 (법정 생략 가능)"
|
||||
if skip_allowed
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
return curves, warnings
|
||||
|
||||
|
||||
def evaluate(
|
||||
pvi_s: np.ndarray,
|
||||
pvi_z: np.ndarray,
|
||||
curves: list[dict[str, Any]],
|
||||
targets: np.ndarray,
|
||||
) -> np.ndarray:
|
||||
"""직선 + 종단곡선으로 구성된 계획선을 임의 chainage에서 평가한다."""
|
||||
values = np.interp(targets, pvi_s, pvi_z)
|
||||
for curve in curves:
|
||||
if curve["omitted"]:
|
||||
continue
|
||||
start, end = curve["bvc_m"], curve["evc_m"]
|
||||
mask = (targets >= start) & (targets <= end)
|
||||
if not np.any(mask):
|
||||
continue
|
||||
local = targets[mask] - start
|
||||
half = curve["length_m"] / 2.0
|
||||
start_z = curve["elevation_m"] - curve["grade_in"] * half
|
||||
delta = curve["grade_out"] - curve["grade_in"]
|
||||
values[mask] = (
|
||||
start_z + curve["grade_in"] * local + delta / (2.0 * curve["length_m"]) * local * local
|
||||
)
|
||||
return values
|
||||
|
||||
|
||||
def _trapezoid_weights(chainage: np.ndarray) -> np.ndarray:
|
||||
"""사다리꼴 적분용 샘플 가중치(ds)."""
|
||||
weights = np.zeros_like(chainage)
|
||||
if len(chainage) < 2:
|
||||
return weights
|
||||
weights[1:-1] = (chainage[2:] - chainage[:-2]) / 2.0
|
||||
weights[0] = (chainage[1] - chainage[0]) / 2.0
|
||||
weights[-1] = (chainage[-1] - chainage[-2]) / 2.0
|
||||
return weights
|
||||
|
||||
|
||||
def _segments(pvi_s: np.ndarray, pvi_z: np.ndarray) -> list[dict[str, Any]]:
|
||||
"""변화점 사이 직선 구간의 연장·고저차·구배(%)."""
|
||||
rows: list[dict[str, Any]] = []
|
||||
for index in range(len(pvi_s) - 1):
|
||||
length = float(pvi_s[index + 1] - pvi_s[index])
|
||||
height = float(pvi_z[index + 1] - pvi_z[index])
|
||||
rows.append(
|
||||
{
|
||||
"index": index,
|
||||
"from_m": round(float(pvi_s[index]), 6),
|
||||
"to_m": round(float(pvi_s[index + 1]), 6),
|
||||
"length_m": round(length, 6),
|
||||
"height_m": round(height, 6),
|
||||
"grade_percent": round(height / length * 100.0 if length > 0 else 0.0, 6),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _station_rows(
|
||||
stations: list[dict[str, Any]],
|
||||
pvi_s: np.ndarray,
|
||||
pvi_z: np.ndarray,
|
||||
curves: list[dict[str, Any]],
|
||||
chainage: np.ndarray,
|
||||
ground: np.ndarray,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""도면 테이블 한 열에 해당하는 측점별 값 (거리·누가거리·지반고·계획고·절성토고)."""
|
||||
if not stations:
|
||||
return []
|
||||
targets = np.array([float(item["chainage_m"]) for item in stations], dtype=np.float64)
|
||||
plan = evaluate(pvi_s, pvi_z, curves, targets)
|
||||
natural = np.interp(targets, chainage, ground)
|
||||
rows: list[dict[str, Any]] = []
|
||||
for index, station in enumerate(stations):
|
||||
difference = float(natural[index] - plan[index])
|
||||
rows.append(
|
||||
{
|
||||
"station_id": station.get("station_id"),
|
||||
"chainage_m": round(float(targets[index]), 6),
|
||||
"distance_m": round(
|
||||
float(targets[index] - targets[index - 1]) if index else 0.0, 6
|
||||
),
|
||||
"ground_elevation_m": round(float(natural[index]), 6),
|
||||
"plan_elevation_m": round(float(plan[index]), 6),
|
||||
"cut_m": round(max(difference, 0.0), 6),
|
||||
"fill_m": round(max(-difference, 0.0), 6),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def build_alignment(
|
||||
*,
|
||||
base_s: np.ndarray,
|
||||
base_z: np.ndarray,
|
||||
chainage: np.ndarray,
|
||||
ground: np.ndarray,
|
||||
stations: list[dict[str, Any]],
|
||||
policy: AlignmentPolicy,
|
||||
edits: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""자동 변화점 + 사용자 편집으로 계획선 선형 전체를 파생한다."""
|
||||
edits = edits or {}
|
||||
station_offsets = {
|
||||
str(key): float(value)
|
||||
for key, value in (edits.get("station_offsets") or {}).items()
|
||||
if value is not None
|
||||
}
|
||||
curve_lengths = {
|
||||
str(key): float(value)
|
||||
for key, value in (edits.get("curve_lengths") or {}).items()
|
||||
if value is not None
|
||||
}
|
||||
|
||||
pvi_s, pvi_z, sources = resolve_pvi(base_s, base_z, station_offsets)
|
||||
curves, warnings = build_curves(pvi_s, pvi_z, policy, curve_lengths)
|
||||
segments = _segments(pvi_s, pvi_z)
|
||||
plan = evaluate(pvi_s, pvi_z, curves, chainage)
|
||||
difference = plan - ground
|
||||
weights = _trapezoid_weights(chainage)
|
||||
|
||||
cut_area = float(weights[difference < 0] @ -difference[difference < 0])
|
||||
fill_area = float(weights[difference > 0] @ difference[difference > 0])
|
||||
reference = max(cut_area, fill_area)
|
||||
imbalance = abs(cut_area - fill_area) / reference * 100.0 if reference > 1e-9 else 0.0
|
||||
|
||||
curve_by_pvi = {curve["pvi_index"]: curve for curve in curves}
|
||||
pvi_rows: list[dict[str, Any]] = []
|
||||
for index in range(len(pvi_s)):
|
||||
curve = curve_by_pvi.get(index)
|
||||
pvi_rows.append(
|
||||
{
|
||||
"chainage_m": round(float(pvi_s[index]), 6),
|
||||
"elevation_m": round(float(pvi_z[index]), 6),
|
||||
"source": sources[index],
|
||||
"kind": "bp" if index == 0 else "ep" if index == len(pvi_s) - 1 else "pvi",
|
||||
"grade_in_pct": round(segments[index - 1]["grade_percent"], 6) if index else None,
|
||||
"grade_out_pct": (
|
||||
round(segments[index]["grade_percent"], 6) if index < len(segments) else None
|
||||
),
|
||||
"curve_l_m": round(float(curve["length_m"]), 6) if curve else None,
|
||||
"curve_r_m": round(float(curve["radius_m"]), 6) if curve else None,
|
||||
}
|
||||
)
|
||||
|
||||
violations = [
|
||||
{
|
||||
"segment_index": segment["index"],
|
||||
"type": "grade_over",
|
||||
"value": segment["grade_percent"],
|
||||
"limit": policy.max_grade_pct,
|
||||
}
|
||||
for segment in segments
|
||||
if abs(segment["grade_percent"]) > policy.max_grade_pct + 1e-6
|
||||
]
|
||||
if violations:
|
||||
worst = max(abs(item["value"]) for item in violations)
|
||||
warnings.append(
|
||||
f"종단기울기 {worst:.2f}%가 기준 {policy.max_grade_pct:.2f}%를 초과하는 구간이 "
|
||||
f"{len(violations)}개 있습니다."
|
||||
)
|
||||
within_tolerance = imbalance <= policy.balance_tolerance_percent + 1e-9
|
||||
if not within_tolerance:
|
||||
warnings.append(
|
||||
f"절·성토 불균형 {imbalance:.1f}%가 허용치 "
|
||||
f"{policy.balance_tolerance_percent:.1f}%를 초과합니다."
|
||||
)
|
||||
|
||||
return {
|
||||
"schema_version": ALIGNMENT_SCHEMA_VERSION,
|
||||
"policy": policy.as_dict(),
|
||||
"base_pvi": [
|
||||
{"chainage_m": round(float(s), 6), "elevation_m": round(float(z), 6)}
|
||||
for s, z in zip(base_s, base_z)
|
||||
],
|
||||
"edits": {"station_offsets": station_offsets, "curve_lengths": curve_lengths},
|
||||
"pvi": pvi_rows,
|
||||
"segments": segments,
|
||||
"curves": [
|
||||
{
|
||||
"pvi_index": curve["pvi_index"],
|
||||
"chainage_m": round(float(curve["chainage_m"]), 6),
|
||||
"bvc_m": round(float(curve["bvc_m"]), 6),
|
||||
"evc_m": round(float(curve["evc_m"]), 6),
|
||||
"bvc_elevation_m": round(
|
||||
float(evaluate(pvi_s, pvi_z, curves, np.array([curve["bvc_m"]]))[0]), 6
|
||||
),
|
||||
"evc_elevation_m": round(
|
||||
float(evaluate(pvi_s, pvi_z, curves, np.array([curve["evc_m"]]))[0]), 6
|
||||
),
|
||||
"l_m": round(float(curve["length_m"]), 6),
|
||||
"r_m": round(float(curve["radius_m"]), 6),
|
||||
"k": round(float(curve["k"]), 6),
|
||||
"delta_pct": round(float(curve["delta_pct"]), 6),
|
||||
"middle_ordinate_m": round(float(curve["middle_ordinate_m"]), 6),
|
||||
"omitted": curve["omitted"],
|
||||
"skip_allowed": curve["skip_allowed"],
|
||||
"omit_reason": curve["omit_reason"],
|
||||
}
|
||||
for curve in curves
|
||||
],
|
||||
"stations": _station_rows(stations, pvi_s, pvi_z, curves, chainage, ground),
|
||||
"samples": [
|
||||
{
|
||||
"chainage_m": round(float(chainage[index]), 6),
|
||||
"elevation_m": round(float(plan[index]), 6),
|
||||
"ground_elevation_m": round(float(ground[index]), 6),
|
||||
"difference_m": round(float(difference[index]), 6),
|
||||
}
|
||||
for index in range(len(chainage))
|
||||
],
|
||||
"balance": {
|
||||
"cut_area_m2": round(cut_area, 6),
|
||||
"fill_area_m2": round(fill_area, 6),
|
||||
"net_area_m2": round(fill_area - cut_area, 6),
|
||||
"imbalance_percent": round(imbalance, 6),
|
||||
"tolerance_percent": policy.balance_tolerance_percent,
|
||||
"within_tolerance": bool(within_tolerance),
|
||||
},
|
||||
"violations": violations,
|
||||
"warnings": warnings,
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
"""B05 종단 계획선 선형 산출 오케스트레이터 (지반 추종 직선 분할 + 편집 재구성).
|
||||
|
||||
[[B05_wf2_Route_Engine_Grade]] 가 확정한 설계 기준과
|
||||
[[B05_wf2_Route_Engine_Grade_Solver]] 의 수치 계산,
|
||||
[[B05_wf2_Route_Engine_Grade_Alignment]] 의 기하 파생을 묶어
|
||||
`design_profiles` 배열에 넣을 계획선 한 벌을 만든다.
|
||||
|
||||
두 진입점이 있다.
|
||||
- `design_alignment_profile()` : 노선 계산 직후. 직선 분할 DP부터 새로 푼다.
|
||||
- `rebuild_alignment_profile()`: 사용자 편집 확정 시. **저장된 자동 선형(base_pvi)과
|
||||
정책을 그대로 재사용**하고 편집 델타만 다시 얹는다. DP를 다시 돌리면 기준선이
|
||||
흔들려 "원복" 이 원래 위치로 돌아가지 않기 때문이다.
|
||||
"""
|
||||
|
||||
from collections import Counter
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Grade import (
|
||||
GradeDesignOptions,
|
||||
detect_main_direction,
|
||||
ground_profile,
|
||||
)
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Grade_Alignment import (
|
||||
ALIGNMENT_SCHEMA_VERSION,
|
||||
AlignmentPolicy,
|
||||
build_alignment,
|
||||
)
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Grade_Solver import (
|
||||
grade_limits,
|
||||
integration_weights,
|
||||
solve_alignment_elevations,
|
||||
station_breakpoints,
|
||||
)
|
||||
|
||||
ALIGNMENT_PROFILE_ID = "design_grade_line"
|
||||
ALIGNMENT_BASIS = "station_alignment"
|
||||
|
||||
|
||||
def infer_station_interval(stations: list[dict[str, Any]]) -> float:
|
||||
"""측점 목록에서 가장 흔한 간격을 기준 측점간격으로 본다.
|
||||
|
||||
사용자가 추가한 비기준 측점(+18, +15 등)이 섞여 있어도 최빈값이 기준 간격이다.
|
||||
"""
|
||||
gaps: list[float] = []
|
||||
for previous, current in zip(stations[:-1], stations[1:]):
|
||||
gap = round(float(current["chainage_m"]) - float(previous["chainage_m"]), 1)
|
||||
if gap > 0:
|
||||
gaps.append(gap)
|
||||
if not gaps:
|
||||
return 20.0
|
||||
return float(Counter(gaps).most_common(1)[0][0])
|
||||
|
||||
|
||||
def _profile_entry(
|
||||
alignment: dict[str, Any],
|
||||
options: GradeDesignOptions,
|
||||
direction: str,
|
||||
balanced: bool,
|
||||
warnings: list[str],
|
||||
) -> dict[str, Any]:
|
||||
"""`design_profiles` 배열 계약(기존 스키마)에 맞춰 계획선 한 벌을 만든다.
|
||||
|
||||
B06 횡단 계획고와 B07 CAD 계획선 레이어는 `samples`만 사용하므로, 선형 구조가
|
||||
바뀌어도 하류 단계는 그대로 동작한다.
|
||||
"""
|
||||
balance = alignment["balance"]
|
||||
grades = [abs(segment["grade_percent"]) for segment in alignment["segments"]]
|
||||
return {
|
||||
"schema_version": ALIGNMENT_SCHEMA_VERSION,
|
||||
"id": ALIGNMENT_PROFILE_ID,
|
||||
"name": "계획선",
|
||||
"basis": ALIGNMENT_BASIS,
|
||||
"criteria": {**options.as_dict(), "resolved_main_direction": direction},
|
||||
# 구 스키마 호환: 변화점 목록을 pvis 이름으로도 노출한다.
|
||||
"pvis": alignment["pvi"],
|
||||
"samples": alignment["samples"],
|
||||
"stations": alignment["stations"],
|
||||
"balance_segments": [
|
||||
{
|
||||
"index": 0,
|
||||
"start_chainage_m": alignment["segments"][0]["from_m"]
|
||||
if alignment["segments"]
|
||||
else 0.0,
|
||||
"end_chainage_m": alignment["segments"][-1]["to_m"]
|
||||
if alignment["segments"]
|
||||
else 0.0,
|
||||
"cut_area_m2": balance["cut_area_m2"],
|
||||
"fill_area_m2": balance["fill_area_m2"],
|
||||
"balance_error_m2": balance["net_area_m2"],
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"cut_area_m2": balance["cut_area_m2"],
|
||||
"fill_area_m2": balance["fill_area_m2"],
|
||||
"balance_error_m2": balance["net_area_m2"],
|
||||
"imbalance_percent": balance["imbalance_percent"],
|
||||
"tolerance_percent": balance["tolerance_percent"],
|
||||
"max_grade_pct": round(max(grades), 6) if grades else 0.0,
|
||||
"vertical_curve_count": sum(1 for curve in alignment["curves"] if not curve["omitted"]),
|
||||
"pvi_count": len(alignment["pvi"]),
|
||||
"balance_segment_count": 1,
|
||||
"balanced": bool(balanced and balance["within_tolerance"]),
|
||||
"main_direction": direction,
|
||||
"suggested_elevation_offset_m": None,
|
||||
"edited_station_count": len(alignment["edits"]["station_offsets"]),
|
||||
"warnings": warnings,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def design_alignment_profile(
|
||||
longitudinal: dict[str, Any],
|
||||
options: GradeDesignOptions,
|
||||
*,
|
||||
station_interval_m: float | None = None,
|
||||
edits: dict[str, Any] | None = None,
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""지반 종단을 직선 분할로 근사해 계획선 선형을 새로 만든다.
|
||||
|
||||
반환값은 (선형 구조, `design_profiles` 항목) 이다.
|
||||
"""
|
||||
options.validate()
|
||||
chainage, ground = ground_profile(longitudinal)
|
||||
total = float(chainage[-1])
|
||||
if total <= 0:
|
||||
raise ValueError("종단 연장이 0이어서 계획선을 만들 수 없습니다.")
|
||||
|
||||
stations = list(longitudinal.get("stations") or [])
|
||||
interval = float(station_interval_m or 0) or infer_station_interval(stations)
|
||||
policy = AlignmentPolicy.from_config(
|
||||
station_interval_m=interval,
|
||||
max_grade_pct=options.max_grade_pct,
|
||||
curve_skip_delta_pct=options.vertical_curve_skip_delta_pct,
|
||||
paved=options.paved,
|
||||
)
|
||||
|
||||
warnings = list(options.warnings)
|
||||
fixed = (
|
||||
float(ground[0]) + options.start_elevation_offset_m,
|
||||
float(ground[-1]) + options.end_elevation_offset_m,
|
||||
)
|
||||
rise = fixed[1] - fixed[0]
|
||||
direction, note = (
|
||||
detect_main_direction(ground, rise)
|
||||
if options.main_direction == "auto"
|
||||
else (options.main_direction, None)
|
||||
)
|
||||
if note:
|
||||
warnings.append(note)
|
||||
up_limit, down_limit = grade_limits(
|
||||
options.max_grade_pct, options.max_reverse_grade_pct, direction
|
||||
)
|
||||
limit = up_limit if rise >= 0 else down_limit
|
||||
if abs(rise) / total > limit + 1e-9:
|
||||
raise ValueError(
|
||||
f"시·종점 고도차({rise:.2f}m)를 연장 {total:.1f}m에서 기준 기울기 "
|
||||
f"{limit * 100:.1f}% 이내로 연결할 수 없습니다."
|
||||
)
|
||||
|
||||
station_chainages = np.array(
|
||||
[float(station["chainage_m"]) for station in stations], dtype=np.float64
|
||||
)
|
||||
base_s = station_breakpoints(
|
||||
chainage,
|
||||
ground,
|
||||
station_chainages if len(station_chainages) else chainage,
|
||||
penalty_m2=policy.pvi_penalty_m2,
|
||||
min_segment_stations=policy.min_segment_stations,
|
||||
)
|
||||
base_z, balanced = solve_alignment_elevations(
|
||||
base_s,
|
||||
chainage,
|
||||
ground,
|
||||
integration_weights(chainage),
|
||||
fixed,
|
||||
up_limit,
|
||||
down_limit,
|
||||
balance_tolerance_percent=policy.balance_tolerance_percent,
|
||||
)
|
||||
alignment = build_alignment(
|
||||
base_s=base_s,
|
||||
base_z=base_z,
|
||||
chainage=chainage,
|
||||
ground=ground,
|
||||
stations=stations,
|
||||
policy=policy,
|
||||
edits=edits,
|
||||
)
|
||||
warnings.extend(alignment["warnings"])
|
||||
return alignment, _profile_entry(alignment, options, direction, balanced, warnings)
|
||||
|
||||
|
||||
def rebuild_alignment_profile(
|
||||
longitudinal: dict[str, Any],
|
||||
edits: dict[str, Any] | None,
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""저장된 자동 선형을 기준으로 사용자 편집만 다시 얹는다.
|
||||
|
||||
직선 분할 DP를 다시 돌리지 않으므로 편집 델타의 기준선(base_pvi)이 고정되고,
|
||||
편집 키를 지우면 최초 자동 선형으로 정확히 되돌아간다.
|
||||
"""
|
||||
stored = longitudinal.get("profile_alignment")
|
||||
if not isinstance(stored, dict) or not stored.get("base_pvi"):
|
||||
raise ValueError("저장된 계획선 선형이 없어 편집을 반영할 수 없습니다.")
|
||||
|
||||
chainage, ground = ground_profile(longitudinal)
|
||||
base = stored["base_pvi"]
|
||||
base_s = np.array([float(item["chainage_m"]) for item in base], dtype=np.float64)
|
||||
base_z = np.array([float(item["elevation_m"]) for item in base], dtype=np.float64)
|
||||
policy = AlignmentPolicy.from_dict(stored.get("policy") or {})
|
||||
|
||||
alignment = build_alignment(
|
||||
base_s=base_s,
|
||||
base_z=base_z,
|
||||
chainage=chainage,
|
||||
ground=ground,
|
||||
stations=list(longitudinal.get("stations") or []),
|
||||
policy=policy,
|
||||
edits=edits,
|
||||
)
|
||||
previous = (longitudinal.get("design_profiles") or [{}])[0]
|
||||
criteria = previous.get("criteria") or {}
|
||||
options = GradeDesignOptions(
|
||||
max_grade_pct=float(criteria.get("max_grade_pct") or policy.max_grade_pct),
|
||||
max_reverse_grade_pct=float(criteria.get("max_reverse_grade_pct") or 5.0),
|
||||
min_vertical_radius_m=float(criteria.get("min_vertical_radius_m") or 100.0),
|
||||
min_curve_length_m=float(criteria.get("min_curve_length_m") or 20.0),
|
||||
min_tangent_length_m=float(criteria.get("min_tangent_length_m") or 20.0),
|
||||
vertical_curve_skip_delta_pct=policy.curve_skip_delta_pct,
|
||||
design_speed_kph=int(criteria.get("design_speed_kph") or 20),
|
||||
terrain_type=str(criteria.get("terrain_type") or "normal"),
|
||||
paved=policy.paved,
|
||||
main_direction=str(criteria.get("main_direction") or "auto"),
|
||||
)
|
||||
direction = str(criteria.get("resolved_main_direction") or "none")
|
||||
return alignment, _profile_entry(
|
||||
alignment,
|
||||
options,
|
||||
direction,
|
||||
alignment["balance"]["within_tolerance"],
|
||||
alignment["warnings"],
|
||||
)
|
||||
@@ -20,6 +20,9 @@ BALANCE_TOLERANCE_M = 1e-6
|
||||
_SOFT_BALANCE_WEIGHT = 1.0e4
|
||||
_TRUST_OPTIONS = {"maxiter": 1000, "gtol": 1e-10, "xtol": 1e-12}
|
||||
_SLSQP_OPTIONS = {"maxiter": 500, "ftol": 1e-12}
|
||||
# 균형 허용치(비율) 수렴용: 면적 상한을 다시 잡는 최대 횟수와 목표 여유 계수
|
||||
_BALANCE_TIGHTEN_PASSES = 4
|
||||
_BALANCE_TARGET_MARGIN = 0.8
|
||||
|
||||
|
||||
def integration_weights(chainage: np.ndarray) -> np.ndarray:
|
||||
@@ -276,6 +279,187 @@ def optimize_pvi_elevations(
|
||||
return elevations, False
|
||||
|
||||
|
||||
def _regression_prefix(chainage: np.ndarray, ground: np.ndarray) -> dict[str, np.ndarray]:
|
||||
"""구간 잔차제곱합을 O(1)로 구하기 위한 접두합 묶음."""
|
||||
zeros = np.zeros(1, dtype=np.float64)
|
||||
return {
|
||||
"n": np.concatenate([zeros, np.cumsum(np.ones_like(chainage))]),
|
||||
"x": np.concatenate([zeros, np.cumsum(chainage)]),
|
||||
"xx": np.concatenate([zeros, np.cumsum(chainage * chainage)]),
|
||||
"y": np.concatenate([zeros, np.cumsum(ground)]),
|
||||
"yy": np.concatenate([zeros, np.cumsum(ground * ground)]),
|
||||
"xy": np.concatenate([zeros, np.cumsum(chainage * ground)]),
|
||||
}
|
||||
|
||||
|
||||
def _segment_sse(prefix: dict[str, np.ndarray], start: int, end: int) -> float:
|
||||
"""샘플 [start, end] 구간을 직선으로 최소자승 근사했을 때의 잔차제곱합."""
|
||||
count = prefix["n"][end + 1] - prefix["n"][start]
|
||||
if count < 2:
|
||||
return 0.0
|
||||
sum_x = prefix["x"][end + 1] - prefix["x"][start]
|
||||
sum_y = prefix["y"][end + 1] - prefix["y"][start]
|
||||
centered_xx = (prefix["xx"][end + 1] - prefix["xx"][start]) - sum_x * sum_x / count
|
||||
centered_yy = (prefix["yy"][end + 1] - prefix["yy"][start]) - sum_y * sum_y / count
|
||||
centered_xy = (prefix["xy"][end + 1] - prefix["xy"][start]) - sum_x * sum_y / count
|
||||
if centered_xx <= 1e-12:
|
||||
return float(max(centered_yy, 0.0))
|
||||
return float(max(centered_yy - centered_xy * centered_xy / centered_xx, 0.0))
|
||||
|
||||
|
||||
def station_breakpoints(
|
||||
chainage: np.ndarray,
|
||||
ground: np.ndarray,
|
||||
stations: np.ndarray,
|
||||
*,
|
||||
penalty_m2: float,
|
||||
min_segment_stations: int,
|
||||
) -> np.ndarray:
|
||||
"""지반 종단을 최소 개수의 직선으로 근사하는 변화점 chainage를 고른다.
|
||||
|
||||
변화점 후보를 **기준 측점으로 한정**했기 때문에 동적계획법으로 정확해를 구할
|
||||
수 있다(구간 잔차제곱합이 접두합으로 O(1)이라 전체 O(n²)). 목적함수는
|
||||
``Σ(지반고 − 직선)² + penalty_m2 × 구간 수`` 로, penalty를 올릴수록 직선이
|
||||
길고 적어진다. 반환값은 시·종점을 포함한 변화점 chainage 배열이다.
|
||||
"""
|
||||
nodes = np.unique(np.round(stations.astype(np.float64), 6))
|
||||
nodes = nodes[(nodes >= chainage[0] - 1e-6) & (nodes <= chainage[-1] + 1e-6)]
|
||||
nodes = np.unique(np.concatenate([[float(chainage[0])], nodes, [float(chainage[-1])]]))
|
||||
if len(nodes) < 3:
|
||||
return nodes
|
||||
|
||||
prefix = _regression_prefix(chainage, ground)
|
||||
sample_index = np.clip(np.searchsorted(chainage, nodes), 0, len(chainage) - 1)
|
||||
step = max(1, int(min_segment_stations))
|
||||
count = len(nodes)
|
||||
best = np.full(count, np.inf, dtype=np.float64)
|
||||
previous = np.zeros(count, dtype=np.int64)
|
||||
best[0] = 0.0
|
||||
for end in range(1, count):
|
||||
for start in range(0, end - step + 1):
|
||||
if not np.isfinite(best[start]):
|
||||
continue
|
||||
cost = (
|
||||
best[start]
|
||||
+ _segment_sse(prefix, int(sample_index[start]), int(sample_index[end]))
|
||||
+ penalty_m2
|
||||
)
|
||||
if cost < best[end]:
|
||||
best[end] = cost
|
||||
previous[end] = start
|
||||
if not np.isfinite(best[count - 1]):
|
||||
# 최소 구간 측점 수를 만족하는 분할이 없으면 시·종점 직선 하나로 둔다.
|
||||
return np.array([nodes[0], nodes[-1]], dtype=np.float64)
|
||||
picked = [count - 1]
|
||||
while picked[-1] != 0:
|
||||
picked.append(int(previous[picked[-1]]))
|
||||
return nodes[np.array(sorted(picked), dtype=np.int64)]
|
||||
|
||||
|
||||
def solve_alignment_elevations(
|
||||
node_s: np.ndarray,
|
||||
chainage: np.ndarray,
|
||||
ground: np.ndarray,
|
||||
weights: np.ndarray,
|
||||
fixed: tuple[float, float],
|
||||
up_limit: float,
|
||||
down_limit: float,
|
||||
*,
|
||||
balance_tolerance_percent: float,
|
||||
) -> tuple[np.ndarray, bool]:
|
||||
"""변화점 표고를 "지반 추종 우선 + 균형은 허용 오차 이내" 로 결정한다.
|
||||
|
||||
기존 [[optimize_pvi_elevations]] 는 구역별 절·성토 균형을 **등식 제약**으로
|
||||
강제해 계획선이 지반 형상에서 멀어지곤 했다. 여기서는 지반 추종을 목적으로
|
||||
두고, 균형은 `|절토−성토| / max(절토,성토) ≤ 허용치` 를 만족할 때까지만
|
||||
순 면적을 부등식으로 조인다. 종단기울기 상한은 법정 기준이라 항상 강제한다.
|
||||
"""
|
||||
matrix = _interp_matrix(node_s, chainage)
|
||||
free = np.arange(1, len(node_s) - 1)
|
||||
base = np.zeros(len(node_s), dtype=np.float64)
|
||||
base[0], base[-1] = fixed
|
||||
if not len(free):
|
||||
return base, True
|
||||
|
||||
matrix_free = matrix[:, free]
|
||||
offset = matrix @ base - ground
|
||||
scale = float(weights.sum()) or 1.0
|
||||
normalized = weights / scale
|
||||
|
||||
def objective(x: np.ndarray) -> float:
|
||||
residual = matrix_free @ x + offset
|
||||
return float(residual @ (normalized * residual))
|
||||
|
||||
def objective_jac(x: np.ndarray) -> np.ndarray:
|
||||
residual = matrix_free @ x + offset
|
||||
return 2.0 * (matrix_free.T @ (normalized * residual))
|
||||
|
||||
spans = node_s[1:] - node_s[:-1]
|
||||
difference = np.zeros((len(spans), len(node_s)), dtype=np.float64)
|
||||
rows = np.arange(len(spans))
|
||||
difference[rows, rows] = -1.0 / spans
|
||||
difference[rows, rows + 1] = 1.0 / spans
|
||||
difference_free = difference[:, free]
|
||||
difference_base = difference @ base
|
||||
grade_constraint = LinearConstraint(
|
||||
difference_free, -down_limit - difference_base, up_limit - difference_base
|
||||
)
|
||||
|
||||
def solve(constraints: list[LinearConstraint], initial: np.ndarray) -> np.ndarray:
|
||||
try:
|
||||
result = minimize(
|
||||
objective,
|
||||
initial,
|
||||
jac=objective_jac,
|
||||
constraints=constraints,
|
||||
method="SLSQP",
|
||||
options=_SLSQP_OPTIONS,
|
||||
)
|
||||
except (ValueError, np.linalg.LinAlgError):
|
||||
return initial
|
||||
return np.asarray(result.x, dtype=np.float64)
|
||||
|
||||
def imbalance_of(x: np.ndarray) -> tuple[float, float, float]:
|
||||
elevations = base.copy()
|
||||
elevations[free] = x
|
||||
gap = matrix @ elevations - ground
|
||||
cut = float(weights[gap < 0] @ -gap[gap < 0])
|
||||
fill = float(weights[gap > 0] @ gap[gap > 0])
|
||||
reference = max(cut, fill)
|
||||
return cut, fill, (abs(cut - fill) / reference * 100.0 if reference > 1e-9 else 0.0)
|
||||
|
||||
following = np.interp(node_s[free], chainage, ground)
|
||||
solution = solve([grade_constraint], following)
|
||||
|
||||
# 허용치를 넘으면 순 면적(∫(계획고−지반고)ds)을 조여 다시 푼다.
|
||||
# 허용치는 |절토−성토| / max(절토,성토) 라는 **비율**이라 면적 상한을 한 번만
|
||||
# 잡으면 재계산 후 분모(max)가 줄어들며 비율이 다시 넘칠 수 있다. 분모를 갱신하며
|
||||
# 몇 번 조여 들어가고, 더 못 줄이면 그 시점의 최선을 채택한다.
|
||||
area = _polyline_area_matrix(node_s, float(node_s[0]), float(node_s[-1]))
|
||||
ground_area = float(weights @ ground)
|
||||
span = max(float(node_s[-1] - node_s[0]), 1e-9)
|
||||
_, _, imbalance = imbalance_of(solution)
|
||||
for _ in range(_BALANCE_TIGHTEN_PASSES):
|
||||
if imbalance <= balance_tolerance_percent + 1e-9:
|
||||
break
|
||||
cut, fill, _ = imbalance_of(solution)
|
||||
# 목표를 허용치보다 조금 더 조여, 분모가 줄어도 비율이 상한 안에 남게 한다.
|
||||
tolerance_area = balance_tolerance_percent / 100.0 * max(cut, fill) * _BALANCE_TARGET_MARGIN
|
||||
balance_constraint = LinearConstraint(
|
||||
area[free][None, :] / span,
|
||||
(ground_area - tolerance_area - float(area @ base)) / span,
|
||||
(ground_area + tolerance_area - float(area @ base)) / span,
|
||||
)
|
||||
candidate = solve([grade_constraint, balance_constraint], solution)
|
||||
_, _, candidate_imbalance = imbalance_of(candidate)
|
||||
if candidate_imbalance >= imbalance - 1e-9:
|
||||
break
|
||||
solution, imbalance = candidate, candidate_imbalance
|
||||
elevations = base.copy()
|
||||
elevations[free] = solution
|
||||
return elevations, imbalance <= balance_tolerance_percent + 1e-9
|
||||
|
||||
|
||||
def build_vertical_curves(
|
||||
pvi_s: np.ndarray,
|
||||
pvi_z: np.ndarray,
|
||||
|
||||
@@ -12,6 +12,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Grade import GradeDesignOptions, design_grade_line
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Grade_Profile import design_alignment_profile
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import (
|
||||
SectionGenerationOptions,
|
||||
generate_sections,
|
||||
@@ -78,21 +79,32 @@ def _cross_summary(cross_section: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _append_design_profiles(
|
||||
longitudinal: dict[str, Any], grade_options: GradeDesignOptions | None
|
||||
longitudinal: dict[str, Any],
|
||||
grade_options: GradeDesignOptions | None,
|
||||
station_interval_m: float | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""종단 계획선을 산출해 longitudinal에 붙이고 요약을 반환한다.
|
||||
|
||||
계획선 산출 실패가 종횡단 생성 자체를 무효화하지 않도록 예외를 격리한다.
|
||||
1순위는 측점 제약 직선 분할 선형(`profile_alignment`)이며, 여기서 산출된
|
||||
변화점 구조가 사용자 편집의 기준선이 된다. 산출이 실패하면 구 균형 최적화
|
||||
계획선으로 폴백하고(편집 불가), 그마저 실패해도 종횡단 생성 자체는 유지한다.
|
||||
횡단 설계 기반 계획선을 나중에 추가할 수 있게 배열로 보관한다.
|
||||
"""
|
||||
longitudinal.setdefault("design_profiles", [])
|
||||
if grade_options is None:
|
||||
return None
|
||||
try:
|
||||
profile = design_grade_line(longitudinal, grade_options)
|
||||
alignment, profile = design_alignment_profile(
|
||||
longitudinal, grade_options, station_interval_m=station_interval_m
|
||||
)
|
||||
longitudinal["profile_alignment"] = alignment
|
||||
except (ValueError, KeyError, ArithmeticError):
|
||||
logger.exception("B05 종단 계획선 산출 실패 (종횡단은 유지)")
|
||||
return None
|
||||
logger.exception("B05 계획선 선형 산출 실패 — 균형 최적화 계획선으로 대체")
|
||||
try:
|
||||
profile = design_grade_line(longitudinal, grade_options)
|
||||
except (ValueError, KeyError, ArithmeticError):
|
||||
logger.exception("B05 종단 계획선 산출 실패 (종횡단은 유지)")
|
||||
return None
|
||||
longitudinal["design_profiles"].append(profile)
|
||||
return {"id": profile["id"], **profile["summary"]}
|
||||
|
||||
@@ -135,7 +147,11 @@ def run_section_generation(
|
||||
cross_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 종단면 저장 (계획선은 저장 직전에 종단 데이터에 붙인다)
|
||||
grade_summary = _append_design_profiles(result["longitudinal"], grade_options)
|
||||
grade_summary = _append_design_profiles(
|
||||
result["longitudinal"],
|
||||
grade_options,
|
||||
(result.get("options") or {}).get("station_interval_m"),
|
||||
)
|
||||
long_file = long_dir / "longitudinal.json"
|
||||
atomic_write_json(long_file, result["longitudinal"])
|
||||
long_summary = {
|
||||
|
||||
@@ -208,6 +208,39 @@ async def confirm_route(connection: aiomysql.Connection, route_id: int) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def update_longitudinal_grade_summary(
|
||||
connection: aiomysql.Connection,
|
||||
*,
|
||||
route_id: int,
|
||||
grade_summary: dict[str, Any] | None,
|
||||
) -> None:
|
||||
"""계획선 편집 저장 시 longitudinal_sections.data의 grade_summary만 갱신한다.
|
||||
|
||||
측점·반폭 등 다른 옵션 스냅샷은 그대로 두어야 하므로 data 전체를 덮어쓰지 않고
|
||||
읽어서 해당 키만 바꿔 다시 쓴다(단일 소스 유지).
|
||||
"""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT id, data FROM longitudinal_sections
|
||||
WHERE route_id = %s ORDER BY id DESC LIMIT 1
|
||||
""",
|
||||
(route_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return
|
||||
stored = row[1]
|
||||
if isinstance(stored, str):
|
||||
stored = json.loads(stored)
|
||||
data = stored if isinstance(stored, dict) else {}
|
||||
data["grade_summary"] = grade_summary
|
||||
await cursor.execute(
|
||||
"UPDATE longitudinal_sections SET data = %s WHERE id = %s",
|
||||
(json.dumps(data, ensure_ascii=False), int(row[0])),
|
||||
)
|
||||
|
||||
|
||||
async def get_surface_crs_epsg(
|
||||
connection: aiomysql.Connection, project_id: UUID, surface_model_id: int
|
||||
) -> int | None:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""B05 경로 설계 FastAPI 라우터."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -14,6 +15,7 @@ from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_
|
||||
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_Grade import GradeDesignOptions, resolve_grade_options
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Grade_Profile import rebuild_alignment_profile
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Sections import run_section_generation
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import SectionGenerationOptions
|
||||
from B05_wf2_Route.B05_wf2_Route_Repository import (
|
||||
@@ -24,11 +26,14 @@ from B05_wf2_Route.B05_wf2_Route_Repository import (
|
||||
get_route_points,
|
||||
get_surface_crs_epsg,
|
||||
insert_route_points,
|
||||
update_longitudinal_grade_summary,
|
||||
)
|
||||
from B05_wf2_Route.B05_wf2_Route_Schema import (
|
||||
GRADE_PERCENT_FIELDS,
|
||||
ContourIntervalUpdateRequest,
|
||||
ContourIntervalUpdateResponse,
|
||||
ProfileAlignmentSaveRequest,
|
||||
ProfileAlignmentSaveResponse,
|
||||
RouteConfirmResponse,
|
||||
RouteLatestResponse,
|
||||
RouteSolveRequest,
|
||||
@@ -40,8 +45,10 @@ from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import (
|
||||
delete_sections_for_route,
|
||||
get_latest_grade_options,
|
||||
get_latest_section_options,
|
||||
get_longitudinal_section,
|
||||
insert_cross_sections,
|
||||
)
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_surface_confirmation import (
|
||||
get_surface_confirmation_params,
|
||||
@@ -376,6 +383,81 @@ async def update_contour_interval(
|
||||
)
|
||||
|
||||
|
||||
def _apply_alignment_edits(
|
||||
project_root: Path, longitudinal_file_path: str, edits: dict[str, Any]
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""저장된 종단 JSON에 계획선 편집을 반영해 정본을 다시 쓰고 (선형, 요약)을 반환한다.
|
||||
|
||||
화면이 즉시 계산해 보여준 값과 같은 기하식을 서버에서도 다시 적용해, 파일에
|
||||
남는 정본이 항상 한 곳(백엔드)에서 만들어지게 한다.
|
||||
"""
|
||||
root = project_root.resolve()
|
||||
path = (root / longitudinal_file_path).resolve()
|
||||
if root not in path.parents:
|
||||
raise ValueError("종단 데이터 경로가 프로젝트 밖을 가리킵니다.")
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError("종단 데이터 파일을 찾을 수 없습니다.")
|
||||
longitudinal = json.loads(path.read_text(encoding="utf-8"))
|
||||
alignment, profile = rebuild_alignment_profile(longitudinal, edits)
|
||||
longitudinal["profile_alignment"] = alignment
|
||||
profiles = longitudinal.get("design_profiles")
|
||||
if isinstance(profiles, list) and profiles:
|
||||
profiles[0] = profile
|
||||
else:
|
||||
longitudinal["design_profiles"] = [profile]
|
||||
atomic_write_json(path, longitudinal)
|
||||
return alignment, {"id": profile["id"], **profile["summary"]}
|
||||
|
||||
|
||||
@router.put("/{project_id}/route/profile-alignment", response_model=ProfileAlignmentSaveResponse)
|
||||
async def save_profile_alignment(
|
||||
project_id: UUID, request: ProfileAlignmentSaveRequest
|
||||
) -> ProfileAlignmentSaveResponse | JSONResponse:
|
||||
"""종단 계획선 사용자 편집(측점 계획고·종단곡선)을 영속화한다."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
longitudinal = await get_longitudinal_section(connection, project_id, request.route_id)
|
||||
if not longitudinal:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "저장된 종단 데이터가 없습니다."},
|
||||
)
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
alignment, grade_summary = await asyncio.to_thread(
|
||||
_apply_alignment_edits,
|
||||
project_root,
|
||||
str(longitudinal["longitudinal_file_path"]),
|
||||
request.edits(),
|
||||
)
|
||||
await connection.begin()
|
||||
try:
|
||||
await update_longitudinal_grade_summary(
|
||||
connection, route_id=request.route_id, grade_summary=grade_summary
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
return ProfileAlignmentSaveResponse(
|
||||
project_id=str(project_id),
|
||||
route_id=request.route_id,
|
||||
profile_alignment=alignment,
|
||||
grade_summary=grade_summary,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except (OSError, ValueError, json.JSONDecodeError) 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 입력 스냅샷을 반환한다."""
|
||||
|
||||
@@ -169,6 +169,43 @@ class ContourIntervalUpdateResponse(BaseModel):
|
||||
contour_interval_m: float
|
||||
|
||||
|
||||
class ProfileAlignmentSaveRequest(BaseModel):
|
||||
"""종단 계획선 사용자 편집 저장 요청.
|
||||
|
||||
화면은 편집 결과를 즉시 계산해 보여주고, 확정 시점에 **편집 델타만** 보낸다.
|
||||
서버가 저장된 자동 선형(base_pvi)에 델타를 다시 얹어 정본을 만들기 때문에
|
||||
표고 전체를 주고받지 않아도 되고, 키를 지우면 자동 선형으로 원복된다.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
route_id: int = Field(gt=0)
|
||||
# {chainage 문자열(소수 3자리): 자동 선형 대비 계획고 델타(m)}
|
||||
station_offsets: dict[str, float] = Field(default_factory=dict)
|
||||
# {chainage 문자열(소수 3자리): 종단곡선 길이(m)}. 화면의 R 입력에서 역산된 값.
|
||||
curve_lengths: dict[str, float] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_edits(self) -> "ProfileAlignmentSaveRequest":
|
||||
for chainage, length in self.curve_lengths.items():
|
||||
if length <= 0:
|
||||
raise ValueError(f"종단곡선 길이는 0보다 커야 합니다 (chainage {chainage}).")
|
||||
return self
|
||||
|
||||
def edits(self) -> dict[str, Any]:
|
||||
return {"station_offsets": self.station_offsets, "curve_lengths": self.curve_lengths}
|
||||
|
||||
|
||||
class ProfileAlignmentSaveResponse(BaseModel):
|
||||
"""계획선 편집 저장 결과 (재계산된 정본 선형을 그대로 돌려준다)."""
|
||||
|
||||
status: str = "success"
|
||||
project_id: str
|
||||
route_id: int
|
||||
profile_alignment: dict[str, Any]
|
||||
grade_summary: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class RouteSolveResponse(BaseModel):
|
||||
"""경로 탐색 실행 결과."""
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
const activeProjectId: string = projectId;
|
||||
|
||||
const viewer = createRouteViewer();
|
||||
const profilePanel = createRouteProfilePanel((stationId) =>
|
||||
const profilePanel = createRouteProfilePanel(activeProjectId, (stationId) =>
|
||||
viewer.markers.selectStation(stationId),
|
||||
);
|
||||
let confirmedSurface: SurfaceModelSummary | null = null;
|
||||
@@ -186,15 +186,15 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
function renderSections(detail: SectionDetailResponse): void {
|
||||
function renderSections(detail: SectionDetailResponse, routeId?: number): void {
|
||||
currentSectionDetail = detail;
|
||||
profilePanel.render(detail, panel.values().stationInterval ?? undefined);
|
||||
profilePanel.render(detail, panel.values().stationInterval ?? undefined, routeId);
|
||||
renderStationLines(detail);
|
||||
}
|
||||
|
||||
async function restoreSections(routeId: number): Promise<void> {
|
||||
try {
|
||||
renderSections(await fetchSectionDetail(activeProjectId, routeId));
|
||||
renderSections(await fetchSectionDetail(activeProjectId, routeId), routeId);
|
||||
} catch {
|
||||
currentSectionDetail = null;
|
||||
profilePanel.clear();
|
||||
@@ -302,6 +302,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
if (!routeReady || stale) return;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
// 종단 계획선 편집은 화면에서만 계산해 두었으므로 확정 직전에 영속화한다.
|
||||
await profilePanel.save();
|
||||
await confirmRoute(activeProjectId);
|
||||
renderLatest(await fetchLatestRoute(activeProjectId));
|
||||
showToast("경로를 확정했습니다.", "success");
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
/* =============================================================================
|
||||
* B05_wf2_Route_UI_Profile_Alignment.ts
|
||||
* 종단 계획선 선형(직선 + 측점 위 종단곡선) 프론트엔드 기하 계산.
|
||||
*
|
||||
* 백엔드 `B05_wf2_Route_Engine_Grade_Alignment.py`와 **같은 식**을 쓴다. 사용자가
|
||||
* 0.1m 버튼을 누를 때마다 서버를 오가지 않고 즉시 다시 그리기 위한 계산부이며,
|
||||
* 확정 시점에는 편집 델타만 서버로 보내 백엔드가 정본을 다시 만든다.
|
||||
*
|
||||
* 규칙:
|
||||
* - 변화점(PVI)은 기준 측점 위에만 놓인다 → 곡선 중심이 측점 수직선상에 있다.
|
||||
* - 종단곡선은 변화점 대칭이며 좌우에 직선이 반드시 남는다.
|
||||
* - 편집 델타는 항상 **자동 선형(base_pvi) 기준**이라 키를 지우면 정확히 원복된다.
|
||||
* ========================================================================== */
|
||||
|
||||
export interface AlignmentPolicy {
|
||||
station_interval_m: number;
|
||||
curve_length_ratio: number;
|
||||
curve_length_min_m: number;
|
||||
curve_tangent_max_ratio: number;
|
||||
curve_skip_legal_exception: boolean;
|
||||
default_curve_length_m: number;
|
||||
balance_tolerance_percent: number;
|
||||
edit_step_m: number;
|
||||
grade_violation_policy: string;
|
||||
max_grade_pct: number;
|
||||
curve_skip_delta_pct: number;
|
||||
paved: boolean;
|
||||
}
|
||||
|
||||
export interface AlignmentNode {
|
||||
chainage_m: number;
|
||||
elevation_m: number;
|
||||
}
|
||||
|
||||
export interface AlignmentPvi extends AlignmentNode {
|
||||
source: "auto" | "user";
|
||||
kind: string;
|
||||
grade_in_pct: number | null;
|
||||
grade_out_pct: number | null;
|
||||
curve_l_m: number | null;
|
||||
curve_r_m: number | null;
|
||||
}
|
||||
|
||||
export interface AlignmentSegment {
|
||||
index: number;
|
||||
from_m: number;
|
||||
to_m: number;
|
||||
length_m: number;
|
||||
height_m: number;
|
||||
grade_percent: number;
|
||||
}
|
||||
|
||||
export interface AlignmentCurve {
|
||||
pvi_index: number;
|
||||
chainage_m: number;
|
||||
bvc_m: number;
|
||||
evc_m: number;
|
||||
bvc_elevation_m: number;
|
||||
evc_elevation_m: number;
|
||||
l_m: number;
|
||||
r_m: number;
|
||||
k: number;
|
||||
delta_pct: number;
|
||||
middle_ordinate_m: number;
|
||||
omitted: boolean;
|
||||
skip_allowed: boolean;
|
||||
omit_reason: string | null;
|
||||
}
|
||||
|
||||
export interface AlignmentStationRow {
|
||||
station_id: string | null;
|
||||
chainage_m: number;
|
||||
distance_m: number;
|
||||
ground_elevation_m: number;
|
||||
plan_elevation_m: number;
|
||||
cut_m: number;
|
||||
fill_m: number;
|
||||
}
|
||||
|
||||
export interface AlignmentSample {
|
||||
chainage_m: number;
|
||||
elevation_m: number;
|
||||
ground_elevation_m: number;
|
||||
difference_m: number;
|
||||
}
|
||||
|
||||
export interface AlignmentBalance {
|
||||
cut_area_m2: number;
|
||||
fill_area_m2: number;
|
||||
net_area_m2: number;
|
||||
imbalance_percent: number;
|
||||
tolerance_percent: number;
|
||||
within_tolerance: boolean;
|
||||
}
|
||||
|
||||
export interface AlignmentViolation {
|
||||
segment_index: number;
|
||||
type: string;
|
||||
value: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface AlignmentEdits {
|
||||
station_offsets: Record<string, number>;
|
||||
curve_lengths: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface ProfileAlignment {
|
||||
schema_version: number;
|
||||
policy: AlignmentPolicy;
|
||||
base_pvi: AlignmentNode[];
|
||||
edits: AlignmentEdits;
|
||||
pvi: AlignmentPvi[];
|
||||
segments: AlignmentSegment[];
|
||||
curves: AlignmentCurve[];
|
||||
stations: AlignmentStationRow[];
|
||||
samples: AlignmentSample[];
|
||||
balance: AlignmentBalance;
|
||||
violations: AlignmentViolation[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** 자동 선형과 지반 종단 — 편집을 얹기 위한 고정 입력. */
|
||||
export interface AlignmentBase {
|
||||
policy: AlignmentPolicy;
|
||||
basePvi: AlignmentNode[];
|
||||
chainage: number[];
|
||||
ground: number[];
|
||||
stations: Array<{ station_id: string | null; chainage_m: number }>;
|
||||
}
|
||||
|
||||
/** chainage를 편집 델타 dict의 키로 바꾼다 (백엔드 `chainage_key`와 동일 규칙). */
|
||||
export function chainageKey(value: number): string {
|
||||
return value.toFixed(3);
|
||||
}
|
||||
|
||||
export function emptyEdits(): AlignmentEdits {
|
||||
return { station_offsets: {}, curve_lengths: {} };
|
||||
}
|
||||
|
||||
/** 오름차순 x 배열 위에서의 선형 보간 (범위 밖은 양 끝값으로 클램프). */
|
||||
function interpolate(xs: number[], ys: number[], value: number): number {
|
||||
if (!xs.length) return 0;
|
||||
if (value <= xs[0]) return ys[0];
|
||||
if (value >= xs[xs.length - 1]) return ys[ys.length - 1];
|
||||
let low = 0;
|
||||
let high = xs.length - 1;
|
||||
while (high - low > 1) {
|
||||
const mid = (low + high) >> 1;
|
||||
if (xs[mid] <= value) low = mid;
|
||||
else high = mid;
|
||||
}
|
||||
const span = xs[high] - xs[low];
|
||||
if (span <= 0) return ys[high];
|
||||
return ys[low] + ((ys[high] - ys[low]) * (value - xs[low])) / span;
|
||||
}
|
||||
|
||||
export function toAlignmentBase(alignment: ProfileAlignment): AlignmentBase {
|
||||
return {
|
||||
policy: alignment.policy,
|
||||
basePvi: alignment.base_pvi.map((node) => ({ ...node })),
|
||||
chainage: alignment.samples.map((sample) => sample.chainage_m),
|
||||
ground: alignment.samples.map((sample) => sample.ground_elevation_m),
|
||||
stations: alignment.stations.map((station) => ({
|
||||
station_id: station.station_id,
|
||||
chainage_m: station.chainage_m,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** 자동 변화점에 사용자 편집 측점을 합쳐 최종 변화점 집합을 만든다. */
|
||||
function resolvePvi(
|
||||
base: AlignmentBase,
|
||||
offsets: Record<string, number>,
|
||||
): Array<AlignmentNode & { source: "auto" | "user" }> {
|
||||
const baseS = base.basePvi.map((node) => node.chainage_m);
|
||||
const baseZ = base.basePvi.map((node) => node.elevation_m);
|
||||
const nodes = new Map<number, AlignmentNode & { source: "auto" | "user" }>();
|
||||
base.basePvi.forEach((node) => {
|
||||
nodes.set(Number(node.chainage_m.toFixed(3)), { ...node, source: "auto" });
|
||||
});
|
||||
Object.entries(offsets).forEach(([key, offset]) => {
|
||||
const chainage = Number(Number(key).toFixed(3));
|
||||
if (!Number.isFinite(chainage) || !Number.isFinite(offset)) return;
|
||||
if (chainage < baseS[0] - 1e-6 || chainage > baseS[baseS.length - 1] + 1e-6) return;
|
||||
nodes.set(chainage, {
|
||||
chainage_m: chainage,
|
||||
elevation_m: interpolate(baseS, baseZ, chainage) + offset,
|
||||
source: "user",
|
||||
});
|
||||
});
|
||||
return [...nodes.values()].sort((a, b) => a.chainage_m - b.chainage_m);
|
||||
}
|
||||
|
||||
interface WorkingCurve extends AlignmentCurve {
|
||||
grade_in: number;
|
||||
grade_out: number;
|
||||
}
|
||||
|
||||
/** 각 변화점에 대칭 종단곡선을 삽입한다 (좌우 직선이 남도록 반쪽 길이를 제한). */
|
||||
function buildCurves(
|
||||
pvi: AlignmentNode[],
|
||||
policy: AlignmentPolicy,
|
||||
curveLengths: Record<string, number>,
|
||||
warnings: string[],
|
||||
): WorkingCurve[] {
|
||||
const curves: WorkingCurve[] = [];
|
||||
const skipDelta = policy.curve_skip_delta_pct / 100;
|
||||
for (let index = 1; index < pvi.length - 1; index += 1) {
|
||||
const spanLeft = pvi[index].chainage_m - pvi[index - 1].chainage_m;
|
||||
const spanRight = pvi[index + 1].chainage_m - pvi[index].chainage_m;
|
||||
if (spanLeft <= 0 || spanRight <= 0) continue;
|
||||
const gradeIn = (pvi[index].elevation_m - pvi[index - 1].elevation_m) / spanLeft;
|
||||
const gradeOut = (pvi[index + 1].elevation_m - pvi[index].elevation_m) / spanRight;
|
||||
const delta = gradeOut - gradeIn;
|
||||
if (Math.abs(delta) < 1e-9) continue;
|
||||
const chainage = pvi[index].chainage_m;
|
||||
const key = chainageKey(chainage);
|
||||
const skipAllowed = !policy.paved && Math.abs(delta) <= skipDelta + 1e-12;
|
||||
const requested = curveLengths[key];
|
||||
const desired =
|
||||
Number.isFinite(requested) && requested > 0 ? requested : policy.default_curve_length_m;
|
||||
const halfLimit = Math.min(spanLeft, spanRight) * policy.curve_tangent_max_ratio;
|
||||
const half = Math.min(desired / 2, halfLimit);
|
||||
if (half <= 1e-9) continue;
|
||||
if (desired / 2 - half > 1e-6) {
|
||||
warnings.push(
|
||||
`${chainage.toFixed(1)}m: 인접 직선이 짧아 종단곡선 길이를 ${(half * 2).toFixed(1)}m로 줄였습니다.`,
|
||||
);
|
||||
}
|
||||
const length = half * 2;
|
||||
curves.push({
|
||||
pvi_index: index,
|
||||
chainage_m: chainage,
|
||||
bvc_m: chainage - half,
|
||||
evc_m: chainage + half,
|
||||
bvc_elevation_m: 0,
|
||||
evc_elevation_m: 0,
|
||||
l_m: length,
|
||||
r_m: length / Math.abs(delta),
|
||||
k: length / (Math.abs(delta) * 100),
|
||||
delta_pct: delta * 100,
|
||||
middle_ordinate_m: (Math.abs(delta) * length) / 8,
|
||||
omitted: skipAllowed && policy.curve_skip_legal_exception,
|
||||
skip_allowed: skipAllowed,
|
||||
omit_reason: skipAllowed
|
||||
? `비포장 대수차 ${policy.curve_skip_delta_pct.toFixed(0)}% 이하 (법정 생략 가능)`
|
||||
: null,
|
||||
grade_in: gradeIn,
|
||||
grade_out: gradeOut,
|
||||
});
|
||||
}
|
||||
return curves;
|
||||
}
|
||||
|
||||
/** 직선 + 종단곡선으로 구성된 계획선을 임의 chainage에서 평가한다. */
|
||||
function evaluateAt(
|
||||
pviS: number[],
|
||||
pviZ: number[],
|
||||
curves: WorkingCurve[],
|
||||
chainage: number,
|
||||
): number {
|
||||
for (const curve of curves) {
|
||||
if (curve.omitted) continue;
|
||||
if (chainage < curve.bvc_m || chainage > curve.evc_m) continue;
|
||||
const half = curve.l_m / 2;
|
||||
const local = chainage - curve.bvc_m;
|
||||
const startZ = pviZ[curve.pvi_index] - curve.grade_in * half;
|
||||
const delta = curve.grade_out - curve.grade_in;
|
||||
return startZ + curve.grade_in * local + (delta / (2 * curve.l_m)) * local * local;
|
||||
}
|
||||
return interpolate(pviS, pviZ, chainage);
|
||||
}
|
||||
|
||||
/** 사다리꼴 적분 가중치(ds). */
|
||||
function trapezoidWeights(chainage: number[]): number[] {
|
||||
const weights = new Array<number>(chainage.length).fill(0);
|
||||
if (chainage.length < 2) return weights;
|
||||
for (let index = 1; index < chainage.length - 1; index += 1) {
|
||||
weights[index] = (chainage[index + 1] - chainage[index - 1]) / 2;
|
||||
}
|
||||
weights[0] = (chainage[1] - chainage[0]) / 2;
|
||||
weights[chainage.length - 1] =
|
||||
(chainage[chainage.length - 1] - chainage[chainage.length - 2]) / 2;
|
||||
return weights;
|
||||
}
|
||||
|
||||
export function buildAlignment(base: AlignmentBase, edits: AlignmentEdits): ProfileAlignment {
|
||||
const warnings: string[] = [];
|
||||
const nodes = resolvePvi(base, edits.station_offsets);
|
||||
const pviS = nodes.map((node) => node.chainage_m);
|
||||
const pviZ = nodes.map((node) => node.elevation_m);
|
||||
const curves = buildCurves(nodes, base.policy, edits.curve_lengths, warnings);
|
||||
|
||||
const segments: AlignmentSegment[] = [];
|
||||
for (let index = 0; index < nodes.length - 1; index += 1) {
|
||||
const length = pviS[index + 1] - pviS[index];
|
||||
const height = pviZ[index + 1] - pviZ[index];
|
||||
segments.push({
|
||||
index,
|
||||
from_m: pviS[index],
|
||||
to_m: pviS[index + 1],
|
||||
length_m: length,
|
||||
height_m: height,
|
||||
grade_percent: length > 0 ? (height / length) * 100 : 0,
|
||||
});
|
||||
}
|
||||
curves.forEach((curve) => {
|
||||
curve.bvc_elevation_m = evaluateAt(pviS, pviZ, curves, curve.bvc_m);
|
||||
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],
|
||||
};
|
||||
});
|
||||
|
||||
const weights = trapezoidWeights(base.chainage);
|
||||
let cutArea = 0;
|
||||
let fillArea = 0;
|
||||
samples.forEach((sample, index) => {
|
||||
if (sample.difference_m < 0) cutArea += weights[index] * -sample.difference_m;
|
||||
else fillArea += weights[index] * sample.difference_m;
|
||||
});
|
||||
const reference = Math.max(cutArea, fillArea);
|
||||
const imbalance = reference > 1e-9 ? (Math.abs(cutArea - fillArea) / reference) * 100 : 0;
|
||||
|
||||
const stations: AlignmentStationRow[] = base.stations.map((station, index) => {
|
||||
const plan = evaluateAt(pviS, pviZ, curves, station.chainage_m);
|
||||
const ground = interpolate(base.chainage, base.ground, station.chainage_m);
|
||||
return {
|
||||
station_id: station.station_id,
|
||||
chainage_m: station.chainage_m,
|
||||
distance_m: index ? station.chainage_m - base.stations[index - 1].chainage_m : 0,
|
||||
ground_elevation_m: ground,
|
||||
plan_elevation_m: plan,
|
||||
cut_m: Math.max(ground - plan, 0),
|
||||
fill_m: Math.max(plan - ground, 0),
|
||||
};
|
||||
});
|
||||
|
||||
const curveByPvi = new Map(curves.map((curve) => [curve.pvi_index, curve]));
|
||||
const pviRows: AlignmentPvi[] = nodes.map((node, index) => {
|
||||
const curve = curveByPvi.get(index);
|
||||
return {
|
||||
chainage_m: node.chainage_m,
|
||||
elevation_m: node.elevation_m,
|
||||
source: node.source,
|
||||
kind: index === 0 ? "bp" : index === nodes.length - 1 ? "ep" : "pvi",
|
||||
grade_in_pct: index ? segments[index - 1].grade_percent : null,
|
||||
grade_out_pct: index < segments.length ? segments[index].grade_percent : null,
|
||||
curve_l_m: curve ? curve.l_m : null,
|
||||
curve_r_m: curve ? curve.r_m : null,
|
||||
};
|
||||
});
|
||||
|
||||
const violations: AlignmentViolation[] = segments
|
||||
.filter((segment) => Math.abs(segment.grade_percent) > base.policy.max_grade_pct + 1e-6)
|
||||
.map((segment) => ({
|
||||
segment_index: segment.index,
|
||||
type: "grade_over",
|
||||
value: segment.grade_percent,
|
||||
limit: base.policy.max_grade_pct,
|
||||
}));
|
||||
const withinTolerance = imbalance <= base.policy.balance_tolerance_percent + 1e-9;
|
||||
|
||||
return {
|
||||
schema_version: 1,
|
||||
policy: base.policy,
|
||||
base_pvi: base.basePvi,
|
||||
edits,
|
||||
pvi: pviRows,
|
||||
segments,
|
||||
curves: curves.map(({ grade_in: _in, grade_out: _out, ...rest }) => rest),
|
||||
stations,
|
||||
samples,
|
||||
balance: {
|
||||
cut_area_m2: cutArea,
|
||||
fill_area_m2: fillArea,
|
||||
net_area_m2: fillArea - cutArea,
|
||||
imbalance_percent: imbalance,
|
||||
tolerance_percent: base.policy.balance_tolerance_percent,
|
||||
within_tolerance: withinTolerance,
|
||||
},
|
||||
violations,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 계획선 위 지점의 계획고.
|
||||
* 측점 위라면 곡선까지 반영해 정확히 계산된 측점 행 값을 쓰고, 그 밖에서는
|
||||
* 샘플 보간으로 폴백한다(편집 판정은 항상 측점 위에서 일어난다).
|
||||
*/
|
||||
export function planElevationAt(alignment: ProfileAlignment, chainageM: number): number {
|
||||
const station = alignment.stations.find((row) => Math.abs(row.chainage_m - chainageM) < 1e-6);
|
||||
if (station) return station.plan_elevation_m;
|
||||
return interpolate(
|
||||
alignment.samples.map((sample) => sample.chainage_m),
|
||||
alignment.samples.map((sample) => sample.elevation_m),
|
||||
chainageM,
|
||||
);
|
||||
}
|
||||
|
||||
const FEEDBACK_PASSES = 4;
|
||||
const FEEDBACK_TOLERANCE_M = 1e-4;
|
||||
|
||||
/**
|
||||
* 측점 계획고를 delta만큼 올리거나 내린 편집 델타를 만든다.
|
||||
*
|
||||
* 변화점이 새로 생기면 종단곡선의 중앙종거만큼 계획고가 함께 내려가(또는 올라가)
|
||||
* 버튼 1클릭이 정확히 0.1m가 되지 않는다. **화면에 보이는 계획고**가 정확히 delta만큼
|
||||
* 움직이도록 중앙종거 변화분을 몇 번 되먹여 보정한다.
|
||||
*/
|
||||
export function adjustStation(
|
||||
base: AlignmentBase,
|
||||
edits: AlignmentEdits,
|
||||
chainageM: number,
|
||||
delta: number,
|
||||
): AlignmentEdits {
|
||||
const key = chainageKey(chainageM);
|
||||
const current = buildAlignment(base, edits);
|
||||
const target = planElevationAt(current, chainageM) + delta;
|
||||
const baseElevation = interpolate(
|
||||
base.basePvi.map((node) => node.chainage_m),
|
||||
base.basePvi.map((node) => node.elevation_m),
|
||||
chainageM,
|
||||
);
|
||||
let offset =
|
||||
(edits.station_offsets[key] ?? planElevationAt(current, chainageM) - baseElevation) + delta;
|
||||
const withOffset = (value: number): AlignmentEdits => ({
|
||||
...edits,
|
||||
station_offsets: { ...edits.station_offsets, [key]: Number(value.toFixed(6)) },
|
||||
});
|
||||
let next = withOffset(offset);
|
||||
for (let pass = 0; pass < FEEDBACK_PASSES; pass += 1) {
|
||||
const error = target - planElevationAt(buildAlignment(base, next), chainageM);
|
||||
if (Math.abs(error) < FEEDBACK_TOLERANCE_M) break;
|
||||
offset += error;
|
||||
next = withOffset(offset);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* 직선 구간 전체를 평행이동한다 (기울기 유지, 양 끝 변화점 동시 이동).
|
||||
*
|
||||
* 측점 버튼은 그 점을 꺾는 조작이라 "구배는 그대로 두고 높이만" 옮길 수 없다.
|
||||
* 구간 양 끝에 같은 델타를 주면 그 직선의 기울기는 보존되고 인접 직선의 각도만 바뀐다.
|
||||
*/
|
||||
export function shiftSegment(
|
||||
base: AlignmentBase,
|
||||
edits: AlignmentEdits,
|
||||
segment: AlignmentSegment,
|
||||
delta: number,
|
||||
): AlignmentEdits {
|
||||
const baseS = base.basePvi.map((node) => node.chainage_m);
|
||||
const baseZ = base.basePvi.map((node) => node.elevation_m);
|
||||
const current = buildAlignment(base, edits);
|
||||
const offsets = { ...edits.station_offsets };
|
||||
[segment.from_m, segment.to_m].forEach((chainage) => {
|
||||
const key = chainageKey(chainage);
|
||||
const existing =
|
||||
offsets[key] ?? planElevationAt(current, chainage) - interpolate(baseS, baseZ, chainage);
|
||||
offsets[key] = Number((existing + delta).toFixed(6));
|
||||
});
|
||||
return { ...edits, station_offsets: offsets };
|
||||
}
|
||||
|
||||
/** 곡선 행에서 R을 고치면 L = R × |A| 로 역산해 곡선 길이 override로 저장한다. */
|
||||
export function setCurveRadius(
|
||||
edits: AlignmentEdits,
|
||||
curve: AlignmentCurve,
|
||||
radiusM: number,
|
||||
): AlignmentEdits {
|
||||
const key = chainageKey(curve.chainage_m);
|
||||
const deltaRatio = Math.abs(curve.delta_pct) / 100;
|
||||
const curveLengths = { ...edits.curve_lengths };
|
||||
if (!Number.isFinite(radiusM) || radiusM <= 0 || deltaRatio < 1e-9) {
|
||||
delete curveLengths[key];
|
||||
} else {
|
||||
curveLengths[key] = Number((radiusM * deltaRatio).toFixed(6));
|
||||
}
|
||||
return { ...edits, curve_lengths: curveLengths };
|
||||
}
|
||||
|
||||
export function hasEdits(edits: AlignmentEdits): boolean {
|
||||
return (
|
||||
Object.keys(edits.station_offsets).length > 0 || Object.keys(edits.curve_lengths).length > 0
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/* =============================================================================
|
||||
* B05_wf2_Route_UI_Profile_Edit.ts
|
||||
* 종단 계획선 편집 상태 보관과 그래프 위 숨김 버튼 오버레이.
|
||||
*
|
||||
* 편집 델타는 세션 저장소에 초안으로 남겨 새로고침해도 작업이 날아가지 않게 하고,
|
||||
* 실제 영속화는 [확정] 시점에 서버로 보낸다.
|
||||
*
|
||||
* 버튼 구성 (평시 투명, 패널 hover 시 노출):
|
||||
* - 측점 ▲ / ▼ : 그 측점을 변화점으로 승격시켜 계획고를 ±step 만큼 꺾는다.
|
||||
* - 구간 ⇧ / ⇩ : 직선 구간 전체를 평행이동한다(구배 유지, 양 끝 변화점 동시 이동).
|
||||
* - 원복 ↺ : 그 측점의 편집 델타만 지워 자동 선형으로 되돌린다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
AlignmentEdits,
|
||||
AlignmentSegment,
|
||||
ProfileAlignment,
|
||||
} from "./B05_wf2_Route_UI_Profile_Alignment";
|
||||
import { chainageKey, emptyEdits, hasEdits } from "./B05_wf2_Route_UI_Profile_Alignment";
|
||||
|
||||
const DRAFT_KEY_PREFIX = "b05-profile-alignment-draft";
|
||||
|
||||
export interface ProfileEditStore {
|
||||
edits(): AlignmentEdits;
|
||||
replace(next: AlignmentEdits): void;
|
||||
/** 서버 저장이 끝났음을 표시한다. 편집 델타는 그대로 두고 초안만 지운다. */
|
||||
markSaved(): void;
|
||||
resetStation(chainageM: number): void;
|
||||
resetAll(): void;
|
||||
/** 아직 서버에 반영되지 않은 변경이 있는가. */
|
||||
dirty(): boolean;
|
||||
/** 자동 선형 대비 편집 델타가 하나라도 있는가. */
|
||||
edited(): boolean;
|
||||
}
|
||||
|
||||
function readDraft(storageKey: string): AlignmentEdits | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(storageKey);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<AlignmentEdits>;
|
||||
return {
|
||||
station_offsets: parsed.station_offsets ?? {},
|
||||
curve_lengths: parsed.curve_lengths ?? {},
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 편집 델타 보관소.
|
||||
*
|
||||
* 초기값은 **서버에 저장된 편집분**이고, 세션 초안이 남아 있으면(= 확정 없이
|
||||
* 새로고침한 경우) 초안을 우선 채택하고 미저장 상태로 표시한다. routeId가 바뀌면
|
||||
* 초안 키도 바뀌어 이전 노선의 편집이 새 노선에 잘못 얹히지 않는다.
|
||||
*/
|
||||
export function createProfileEditStore(
|
||||
routeId: number | null,
|
||||
saved: AlignmentEdits,
|
||||
onChange: () => void,
|
||||
): ProfileEditStore {
|
||||
const storageKey = `${DRAFT_KEY_PREFIX}:${routeId ?? "none"}`;
|
||||
const draft = readDraft(storageKey);
|
||||
let current = draft ?? saved;
|
||||
let unsaved = draft !== null;
|
||||
|
||||
function dropDraft(): void {
|
||||
try {
|
||||
sessionStorage.removeItem(storageKey);
|
||||
} catch {
|
||||
// 세션 저장소를 못 쓰는 환경에서도 편집 자체는 계속 동작해야 한다.
|
||||
}
|
||||
}
|
||||
|
||||
function commit(next: AlignmentEdits): void {
|
||||
current = next;
|
||||
unsaved = true;
|
||||
try {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(current));
|
||||
} catch {
|
||||
// 초안 보관 실패는 편집을 막지 않는다.
|
||||
}
|
||||
onChange();
|
||||
}
|
||||
|
||||
return {
|
||||
edits: () => current,
|
||||
replace: commit,
|
||||
markSaved() {
|
||||
unsaved = false;
|
||||
dropDraft();
|
||||
},
|
||||
resetStation(chainageM) {
|
||||
const key = chainageKey(chainageM);
|
||||
const stationOffsets = { ...current.station_offsets };
|
||||
const curveLengths = { ...current.curve_lengths };
|
||||
delete stationOffsets[key];
|
||||
delete curveLengths[key];
|
||||
commit({ station_offsets: stationOffsets, curve_lengths: curveLengths });
|
||||
},
|
||||
resetAll() {
|
||||
commit(emptyEdits());
|
||||
},
|
||||
dirty: () => unsaved,
|
||||
edited: () => hasEdits(current),
|
||||
};
|
||||
}
|
||||
|
||||
export interface EditOverlayOptions {
|
||||
alignment: ProfileAlignment;
|
||||
width: number;
|
||||
x: (chainageM: number) => number;
|
||||
step: number;
|
||||
onStation: (chainageM: number, delta: number) => void;
|
||||
onSegment: (segment: AlignmentSegment, delta: number) => void;
|
||||
onResetStation: (chainageM: number) => void;
|
||||
}
|
||||
|
||||
function overlayButton(
|
||||
className: string,
|
||||
glyph: string,
|
||||
title: string,
|
||||
onClick: () => void,
|
||||
): HTMLButtonElement {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = `b05-profile-edit__btn ${className}`;
|
||||
button.textContent = glyph;
|
||||
button.title = title;
|
||||
button.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
/** 그래프 영역 위에 겹치는 편집 버튼 층을 만든다 (선 자체는 가리지 않는다). */
|
||||
export function createEditOverlay(options: EditOverlayOptions): HTMLElement {
|
||||
const { alignment, width, x, step, onStation, onSegment, onResetStation } = options;
|
||||
const layer = document.createElement("div");
|
||||
layer.className = "b05-profile-edit";
|
||||
layer.style.width = `${width}px`;
|
||||
|
||||
const edited = new Set(Object.keys(alignment.edits.station_offsets));
|
||||
alignment.stations.forEach((station) => {
|
||||
const left = x(station.chainage_m);
|
||||
const isEdited = edited.has(chainageKey(station.chainage_m));
|
||||
const label = `${station.chainage_m.toFixed(1)}m 계획고 ${station.plan_elevation_m.toFixed(2)}m`;
|
||||
|
||||
const up = overlayButton("is-station is-up", "▲", `${label} — ${step}m 올림`, () =>
|
||||
onStation(station.chainage_m, step),
|
||||
);
|
||||
up.style.left = `${left - 9}px`;
|
||||
const down = overlayButton("is-station is-down", "▼", `${label} — ${step}m 내림`, () =>
|
||||
onStation(station.chainage_m, -step),
|
||||
);
|
||||
down.style.left = `${left - 9}px`;
|
||||
layer.append(up, down);
|
||||
|
||||
if (!isEdited) return;
|
||||
const offset = alignment.edits.station_offsets[chainageKey(station.chainage_m)];
|
||||
const reset = overlayButton(
|
||||
"is-reset",
|
||||
"↺",
|
||||
`${label} — 자동 선형으로 원복 (현재 ${offset >= 0 ? "+" : ""}${offset.toFixed(2)}m)`,
|
||||
() => onResetStation(station.chainage_m),
|
||||
);
|
||||
reset.style.left = `${left - 9}px`;
|
||||
layer.append(reset);
|
||||
});
|
||||
|
||||
alignment.segments.forEach((segment) => {
|
||||
const left = x(segment.from_m);
|
||||
const right = x(segment.to_m);
|
||||
if (right - left < 36) return;
|
||||
const center = (left + right) / 2;
|
||||
const label =
|
||||
`구간 ${segment.from_m.toFixed(0)}~${segment.to_m.toFixed(0)}m ` +
|
||||
`(구배 ${segment.grade_percent.toFixed(2)}%) 전체 평행이동`;
|
||||
const up = overlayButton("is-segment is-up", "⇧", `${label} — ${step}m 올림`, () =>
|
||||
onSegment(segment, step),
|
||||
);
|
||||
up.style.left = `${center - 9}px`;
|
||||
const down = overlayButton("is-segment is-down", "⇩", `${label} — ${step}m 내림`, () =>
|
||||
onSegment(segment, -step),
|
||||
);
|
||||
down.style.left = `${center - 9}px`;
|
||||
layer.append(up, down);
|
||||
});
|
||||
|
||||
return layer;
|
||||
}
|
||||
@@ -1,4 +1,16 @@
|
||||
/* =============================================================================
|
||||
* B05_wf2_Route_UI_Profile_Panel.ts
|
||||
* 하단 종단면도 패널 — 그래프 + 도면 테이블 2단, 계획선 직접 편집.
|
||||
*
|
||||
* 화면 높이의 40%를 쓰며, 그래프와 9행 도면 테이블이 **하나의 가로 스크롤러** 안에
|
||||
* 같은 폭으로 쌓여 X축이 자동으로 맞물린다(스크롤 동기화 코드 불필요).
|
||||
*
|
||||
* 편집은 전부 프론트에서 즉시 계산해 다시 그리고, 영속화는 [확정] 시점에
|
||||
* `saveProfileAlignment()`로 편집 델타만 보낸다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
DesignProfile,
|
||||
LongitudinalSection,
|
||||
SectionDetailResponse,
|
||||
} from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch";
|
||||
@@ -6,49 +18,74 @@ import {
|
||||
createLongitudinalProfile,
|
||||
longitudinalMinimumWidth,
|
||||
} from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal";
|
||||
import { LONG_PAD } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common";
|
||||
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { saveProfileAlignment } from "./B05_wf2_Route_Api_Fetch";
|
||||
import type {
|
||||
AlignmentBase,
|
||||
AlignmentEdits,
|
||||
ProfileAlignment,
|
||||
} from "./B05_wf2_Route_UI_Profile_Alignment";
|
||||
import {
|
||||
adjustStation,
|
||||
buildAlignment,
|
||||
emptyEdits,
|
||||
setCurveRadius,
|
||||
shiftSegment,
|
||||
toAlignmentBase,
|
||||
} from "./B05_wf2_Route_UI_Profile_Alignment";
|
||||
import { createEditOverlay, createProfileEditStore } from "./B05_wf2_Route_UI_Profile_Edit";
|
||||
import { createProfileTable } from "./B05_wf2_Route_UI_Profile_Table";
|
||||
import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css";
|
||||
|
||||
const COLLAPSED_KEY = "b05-route-profile-collapsed";
|
||||
const HORIZONTAL_SCROLLBAR_HEIGHT = 16;
|
||||
const BALANCE_BAR_HEIGHT = 24;
|
||||
const MIN_CHART_HEIGHT = 120;
|
||||
|
||||
/** 계획선 절·성토 균형 결과를 종단도 위에 한 줄 요약으로 보여준다. */
|
||||
function renderBalanceSummary(data: LongitudinalSection): HTMLElement | null {
|
||||
const profile = data.design_profiles?.[0];
|
||||
if (!profile) return null;
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "b05-route-profile__balance";
|
||||
const summary = profile.summary;
|
||||
const entries: Array<[string, string, string?]> = [
|
||||
["절토", `${summary.cut_area_m2.toFixed(1)} m²`, "cut"],
|
||||
["성토", `${summary.fill_area_m2.toFixed(1)} m²`, "fill"],
|
||||
["균형오차", `${summary.balance_error_m2.toFixed(2)} m²${summary.balanced ? "" : " (근사)"}`],
|
||||
["최대 종단기울기", `${summary.max_grade_pct.toFixed(2)} %`],
|
||||
["종단곡선", `${summary.vertical_curve_count} 개`],
|
||||
];
|
||||
if (summary.balance_segment_count > 1) {
|
||||
entries.push(["균형구역", `${summary.balance_segment_count} 개`]);
|
||||
}
|
||||
if (summary.suggested_elevation_offset_m !== null) {
|
||||
entries.push(["시·종점 조정 제안", `${summary.suggested_elevation_offset_m.toFixed(1)} m`]);
|
||||
}
|
||||
entries.forEach(([label, value, tone]) => {
|
||||
const item = document.createElement("span");
|
||||
item.className = `b05-route-profile__balance-item${tone ? ` is-${tone}` : ""}`;
|
||||
const caption = document.createElement("em");
|
||||
caption.textContent = label;
|
||||
item.append(caption, document.createTextNode(value));
|
||||
bar.append(item);
|
||||
});
|
||||
if (summary.warnings.length) {
|
||||
const warning = document.createElement("span");
|
||||
warning.className = "b05-route-profile__balance-warning";
|
||||
warning.textContent = `⚠ ${summary.warnings[0]}`;
|
||||
warning.title = summary.warnings.join("\n");
|
||||
bar.append(warning);
|
||||
}
|
||||
return bar;
|
||||
function readAlignment(data: LongitudinalSection): ProfileAlignment | null {
|
||||
const candidate = data.profile_alignment as ProfileAlignment | undefined;
|
||||
if (!candidate?.base_pvi?.length || !candidate.samples?.length) return null;
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/** 편집 결과를 종단면도 렌더러가 받는 계획선 형태로 감싼다. */
|
||||
function toDesignProfile(
|
||||
alignment: ProfileAlignment,
|
||||
original: DesignProfile | undefined,
|
||||
): DesignProfile {
|
||||
const balance = alignment.balance;
|
||||
return {
|
||||
id: original?.id ?? "design_grade_line",
|
||||
name: original?.name ?? "계획선",
|
||||
basis: original?.basis ?? "station_alignment",
|
||||
samples: alignment.samples,
|
||||
balance_segments: [
|
||||
{
|
||||
index: 0,
|
||||
start_chainage_m: alignment.samples[0]?.chainage_m ?? 0,
|
||||
end_chainage_m: alignment.samples[alignment.samples.length - 1]?.chainage_m ?? 0,
|
||||
cut_area_m2: balance.cut_area_m2,
|
||||
fill_area_m2: balance.fill_area_m2,
|
||||
balance_error_m2: balance.net_area_m2,
|
||||
},
|
||||
],
|
||||
summary: {
|
||||
...(original?.summary ?? {
|
||||
max_grade_pct: 0,
|
||||
vertical_curve_count: 0,
|
||||
pvi_count: 0,
|
||||
balance_segment_count: 1,
|
||||
main_direction: "none",
|
||||
suggested_elevation_offset_m: null,
|
||||
warnings: [],
|
||||
}),
|
||||
cut_area_m2: balance.cut_area_m2,
|
||||
fill_area_m2: balance.fill_area_m2,
|
||||
balance_error_m2: balance.net_area_m2,
|
||||
balanced: balance.within_tolerance,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedLongitudinal(data: LongitudinalSection): LongitudinalSection {
|
||||
@@ -61,50 +98,184 @@ function normalizedLongitudinal(data: LongitudinalSection): LongitudinalSection
|
||||
};
|
||||
}
|
||||
|
||||
export function createRouteProfilePanel(onSelectStation: (stationId: string) => void) {
|
||||
/** 종단면도 렌더러와 **같은** chainage → x(px) 매핑을 만든다 (테이블·버튼 정렬 기준). */
|
||||
function chainageMapper(data: LongitudinalSection, width: number): (chainage: number) => number {
|
||||
const samples = normalizedLongitudinal(data).samples.filter(
|
||||
(sample) => sample.valid !== false && Number.isFinite(sample.elevation_m ?? NaN),
|
||||
);
|
||||
const maxChainage = Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1);
|
||||
const plotWidth = width - LONG_PAD.left - LONG_PAD.right;
|
||||
return (chainage: number) => LONG_PAD.left + (chainage / maxChainage) * plotWidth;
|
||||
}
|
||||
|
||||
export function createRouteProfilePanel(
|
||||
projectId: string,
|
||||
onSelectStation: (stationId: string) => void,
|
||||
) {
|
||||
const root = document.createElement("section");
|
||||
root.className = "b05-route-profile";
|
||||
const panelHandle = createWorkflowPanelHandle("bottom");
|
||||
const toggle = panelHandle.root;
|
||||
const balanceBar = document.createElement("div");
|
||||
balanceBar.className = "b05-route-profile__balance";
|
||||
const body = document.createElement("div");
|
||||
body.className = "b05-route-profile__body";
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "b05-route-profile__empty";
|
||||
empty.textContent = "최적 경로를 계산하면 종단면도가 표시됩니다.";
|
||||
body.append(empty);
|
||||
root.append(toggle, body);
|
||||
root.append(panelHandle.root, balanceBar, body);
|
||||
|
||||
let detail: SectionDetailResponse | null = null;
|
||||
let selectedStationId: string | null = null;
|
||||
let stationInterval: number | undefined;
|
||||
let routeId: number | null = null;
|
||||
let base: AlignmentBase | null = null;
|
||||
let alignment: ProfileAlignment | null = null;
|
||||
let store = createProfileEditStore(null, emptyEdits(), () => rebuild());
|
||||
let resizeTimer = 0;
|
||||
let lastWidth = 0;
|
||||
let lastHeight = 0;
|
||||
|
||||
function renderBalance(): void {
|
||||
balanceBar.replaceChildren();
|
||||
if (!alignment) return;
|
||||
const { balance, policy, violations } = alignment;
|
||||
const entries: Array<[string, string, string?]> = [
|
||||
["절토", `${balance.cut_area_m2.toFixed(1)} m²`, "cut"],
|
||||
["성토", `${balance.fill_area_m2.toFixed(1)} m²`, "fill"],
|
||||
[
|
||||
"불균형",
|
||||
`${balance.imbalance_percent.toFixed(1)} % / 허용 ${balance.tolerance_percent.toFixed(0)} %`,
|
||||
balance.within_tolerance ? undefined : "over",
|
||||
],
|
||||
["변화점", `${alignment.pvi.length} 개`],
|
||||
["종단곡선", `${alignment.curves.filter((curve) => !curve.omitted).length} 개`],
|
||||
["기준 R", `${policy.default_curve_length_m.toFixed(1)} m 곡선길이`],
|
||||
];
|
||||
const editedCount = Object.keys(alignment.edits.station_offsets).length;
|
||||
if (editedCount) entries.push(["편집 측점", `${editedCount} 개`, "edited"]);
|
||||
entries.forEach(([label, value, tone]) => {
|
||||
const item = document.createElement("span");
|
||||
item.className = `b05-route-profile__balance-item${tone ? ` is-${tone}` : ""}`;
|
||||
const caption = document.createElement("em");
|
||||
caption.textContent = label;
|
||||
item.append(caption, document.createTextNode(value));
|
||||
balanceBar.append(item);
|
||||
});
|
||||
if (violations.length) {
|
||||
const warning = document.createElement("span");
|
||||
warning.className = "b05-route-profile__balance-warning";
|
||||
warning.textContent = `⚠ 종단기울기 초과 ${violations.length}개 구간`;
|
||||
warning.title = violations
|
||||
.map((item) => `구간 ${item.segment_index + 1}: ${item.value.toFixed(2)}% > ${item.limit}%`)
|
||||
.join("\n");
|
||||
balanceBar.append(warning);
|
||||
}
|
||||
if (store.edited()) {
|
||||
const reset = document.createElement("button");
|
||||
reset.type = "button";
|
||||
reset.className = "b05-route-profile__balance-reset";
|
||||
reset.textContent = "초기선 복원";
|
||||
reset.title = "모든 편집을 지우고 자동 산출된 계획선으로 되돌립니다.";
|
||||
reset.addEventListener("click", () => store.resetAll());
|
||||
balanceBar.append(reset);
|
||||
}
|
||||
if (store.dirty()) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "b05-route-profile__balance-item is-unsaved";
|
||||
badge.textContent = "미저장 (확정 시 반영)";
|
||||
balanceBar.append(badge);
|
||||
}
|
||||
}
|
||||
|
||||
/** 편집을 적용한다. 법정 위반 정책이 block이면 새 위반이 생기는 편집을 막는다. */
|
||||
function applyEdits(next: AlignmentEdits): void {
|
||||
if (!base || !alignment) return;
|
||||
const candidate = buildAlignment(base, next);
|
||||
if (
|
||||
base.policy.grade_violation_policy === "block" &&
|
||||
candidate.violations.length > alignment.violations.length
|
||||
) {
|
||||
showToast(
|
||||
`종단기울기 상한 ${base.policy.max_grade_pct.toFixed(1)}%를 넘어 편집을 적용하지 않았습니다.`,
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
store.replace(next);
|
||||
}
|
||||
|
||||
function rebuild(): void {
|
||||
if (base) alignment = buildAlignment(base, store.edits());
|
||||
draw();
|
||||
}
|
||||
|
||||
function draw(): void {
|
||||
if (!detail || body.clientWidth <= 0 || body.clientHeight <= 0) return;
|
||||
const availableWidth = Math.max(1, body.clientWidth - 30);
|
||||
const balance = renderBalanceSummary(detail.longitudinal);
|
||||
const height = Math.max(
|
||||
1,
|
||||
body.clientHeight - HORIZONTAL_SCROLLBAR_HEIGHT - (balance ? BALANCE_BAR_HEIGHT : 0),
|
||||
);
|
||||
const minimumWidth = longitudinalMinimumWidth(detail.longitudinal, stationInterval);
|
||||
const width = Math.max(availableWidth, minimumWidth);
|
||||
lastWidth = body.clientWidth;
|
||||
lastHeight = height;
|
||||
const chart = createLongitudinalProfile(
|
||||
normalizedLongitudinal(detail.longitudinal),
|
||||
selectedStationId,
|
||||
1,
|
||||
undefined,
|
||||
onSelectStation,
|
||||
stationInterval,
|
||||
width,
|
||||
height,
|
||||
minimumWidth,
|
||||
detail.longitudinal.design_profiles ?? [],
|
||||
lastHeight = body.clientHeight;
|
||||
renderBalance();
|
||||
|
||||
const longitudinal = detail.longitudinal;
|
||||
const minimumWidth = longitudinalMinimumWidth(longitudinal, stationInterval);
|
||||
const width = Math.max(Math.max(1, body.clientWidth - 30), minimumWidth);
|
||||
const canvas = document.createElement("div");
|
||||
canvas.className = "b05-profile__canvas";
|
||||
canvas.style.width = `${width}px`;
|
||||
|
||||
// 테이블을 먼저 붙여 실제 높이를 재고, 남는 공간 전부를 그래프에 준다.
|
||||
const x = chainageMapper(longitudinal, width);
|
||||
const table = alignment
|
||||
? createProfileTable({
|
||||
alignment,
|
||||
stationInterval: stationInterval ?? alignment.policy.station_interval_m,
|
||||
width,
|
||||
x,
|
||||
onCurveRadiusChange: (curve, radius) =>
|
||||
applyEdits(setCurveRadius(store.edits(), curve, radius ?? 0)),
|
||||
})
|
||||
: null;
|
||||
body.replaceChildren(canvas);
|
||||
if (table) canvas.append(table);
|
||||
const available = body.clientHeight - HORIZONTAL_SCROLLBAR_HEIGHT - (table?.offsetHeight ?? 0);
|
||||
const chartHeight = Math.max(MIN_CHART_HEIGHT, available);
|
||||
|
||||
const chartWrap = document.createElement("div");
|
||||
chartWrap.className = "b05-profile__chart";
|
||||
chartWrap.style.height = `${chartHeight}px`;
|
||||
const designProfiles = alignment
|
||||
? [toDesignProfile(alignment, longitudinal.design_profiles?.[0])]
|
||||
: (longitudinal.design_profiles ?? []);
|
||||
chartWrap.append(
|
||||
createLongitudinalProfile(
|
||||
normalizedLongitudinal(longitudinal),
|
||||
selectedStationId,
|
||||
1,
|
||||
undefined,
|
||||
onSelectStation,
|
||||
stationInterval,
|
||||
width,
|
||||
chartHeight,
|
||||
width,
|
||||
designProfiles,
|
||||
),
|
||||
);
|
||||
body.replaceChildren(...(balance ? [balance, chart] : [chart]));
|
||||
if (alignment) {
|
||||
chartWrap.append(
|
||||
createEditOverlay({
|
||||
alignment,
|
||||
width,
|
||||
x,
|
||||
step: alignment.policy.edit_step_m,
|
||||
onStation: (chainage, delta) =>
|
||||
base && applyEdits(adjustStation(base, store.edits(), chainage, delta)),
|
||||
onSegment: (segment, delta) =>
|
||||
base && applyEdits(shiftSegment(base, store.edits(), segment, delta)),
|
||||
onResetStation: (chainage) => store.resetStation(chainage),
|
||||
}),
|
||||
);
|
||||
}
|
||||
canvas.prepend(chartWrap);
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
@@ -126,14 +297,24 @@ export function createRouteProfilePanel(onSelectStation: (stationId: string) =>
|
||||
if (!collapsed) requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
toggle.addEventListener("click", () => setCollapsed(!root.classList.contains("is-collapsed")));
|
||||
panelHandle.root.addEventListener("click", () =>
|
||||
setCollapsed(!root.classList.contains("is-collapsed")),
|
||||
);
|
||||
setCollapsed(sessionStorage.getItem(COLLAPSED_KEY) === "true");
|
||||
|
||||
return {
|
||||
root,
|
||||
render(nextDetail: SectionDetailResponse, nextStationInterval?: number) {
|
||||
render(nextDetail: SectionDetailResponse, nextStationInterval?: number, nextRouteId?: number) {
|
||||
detail = nextDetail;
|
||||
stationInterval = nextStationInterval;
|
||||
const stored = readAlignment(nextDetail.longitudinal);
|
||||
// 서버 저장분을 기준으로 삼되, 남아 있는 세션 초안이 있으면 그쪽을 우선한다.
|
||||
if (nextRouteId !== routeId || !store.dirty()) {
|
||||
routeId = nextRouteId ?? routeId;
|
||||
store = createProfileEditStore(routeId, stored?.edits ?? emptyEdits(), () => rebuild());
|
||||
}
|
||||
base = stored ? toAlignmentBase(stored) : null;
|
||||
alignment = base ? buildAlignment(base, store.edits()) : null;
|
||||
draw();
|
||||
requestAnimationFrame(draw);
|
||||
},
|
||||
@@ -141,9 +322,26 @@ export function createRouteProfilePanel(onSelectStation: (stationId: string) =>
|
||||
selectedStationId = stationId;
|
||||
draw();
|
||||
},
|
||||
isDirty: () => store.dirty(),
|
||||
/** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */
|
||||
async save(): Promise<void> {
|
||||
if (!routeId || !store.dirty()) return;
|
||||
const saved = await saveProfileAlignment(projectId, routeId, store.edits());
|
||||
const next = saved.profile_alignment as ProfileAlignment | undefined;
|
||||
if (next?.base_pvi?.length) {
|
||||
base = toAlignmentBase(next);
|
||||
alignment = next;
|
||||
if (detail) detail.longitudinal.profile_alignment = next;
|
||||
}
|
||||
store.markSaved();
|
||||
draw();
|
||||
},
|
||||
clear() {
|
||||
detail = null;
|
||||
base = null;
|
||||
alignment = null;
|
||||
selectedStationId = null;
|
||||
balanceBar.replaceChildren();
|
||||
body.replaceChildren(empty);
|
||||
},
|
||||
dispose() {
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/* =============================================================================
|
||||
* B05_wf2_Route_UI_Profile_Table.ts
|
||||
* 종단면도 하단 도면 테이블 (구배·절토고·성토고·계획고·지반고·누가거리·거리·측점·곡선).
|
||||
*
|
||||
* 실무 종단면도 좌측 하단 표를 그대로 옮긴 9행 구성이다. 셀은 종단면도 그래프와
|
||||
* **같은 X 매핑**으로 절대 배치되므로 측점 수직선과 정확히 맞물린다. 마지막 곡선 행의
|
||||
* R만 입력 가능하고 나머지는 전부 파생값이다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
AlignmentCurve,
|
||||
AlignmentSegment,
|
||||
ProfileAlignment,
|
||||
} from "./B05_wf2_Route_UI_Profile_Alignment";
|
||||
import { stationLabel } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common";
|
||||
|
||||
export interface ProfileTableOptions {
|
||||
alignment: ProfileAlignment;
|
||||
stationInterval: number;
|
||||
width: number;
|
||||
/** 종단면도와 공유하는 chainage → x(px) 매핑. */
|
||||
x: (chainageM: number) => number;
|
||||
onCurveRadiusChange: (curve: AlignmentCurve, radiusM: number | null) => void;
|
||||
}
|
||||
|
||||
interface RowSpec {
|
||||
key: string;
|
||||
label: string;
|
||||
/** 측점마다 한 칸씩 채우는 행. */
|
||||
cell?: (index: number) => string;
|
||||
modifier?: string;
|
||||
}
|
||||
|
||||
const CELL_WIDTH = 56;
|
||||
|
||||
function element(tag: string, className: string, text?: string): HTMLElement {
|
||||
const node = document.createElement(tag);
|
||||
node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** 절대 배치 셀: 측점 x를 중심으로 좌우 대칭 배치한다. */
|
||||
function placeCell(row: HTMLElement, centerX: number, node: HTMLElement): void {
|
||||
node.style.left = `${centerX - CELL_WIDTH / 2}px`;
|
||||
node.style.width = `${CELL_WIDTH}px`;
|
||||
row.append(node);
|
||||
}
|
||||
|
||||
function buildStationRows(alignment: ProfileAlignment, interval: number): RowSpec[] {
|
||||
const stations = alignment.stations;
|
||||
return [
|
||||
{
|
||||
key: "cut",
|
||||
label: "절토고",
|
||||
modifier: "cut",
|
||||
cell: (index) => (stations[index].cut_m > 0.005 ? stations[index].cut_m.toFixed(2) : ""),
|
||||
},
|
||||
{
|
||||
key: "fill",
|
||||
label: "성토고",
|
||||
modifier: "fill",
|
||||
cell: (index) => (stations[index].fill_m > 0.005 ? stations[index].fill_m.toFixed(2) : ""),
|
||||
},
|
||||
{
|
||||
key: "plan",
|
||||
label: "계획고",
|
||||
modifier: "plan",
|
||||
cell: (index) => stations[index].plan_elevation_m.toFixed(2),
|
||||
},
|
||||
{
|
||||
key: "ground",
|
||||
label: "지반고",
|
||||
cell: (index) => stations[index].ground_elevation_m.toFixed(2),
|
||||
},
|
||||
{
|
||||
key: "cumulative",
|
||||
label: "누가거리",
|
||||
cell: (index) => stations[index].chainage_m.toFixed(2),
|
||||
},
|
||||
{
|
||||
key: "distance",
|
||||
label: "거리",
|
||||
cell: (index) => (index ? stations[index].distance_m.toFixed(2) : ""),
|
||||
},
|
||||
{
|
||||
key: "station",
|
||||
label: "측점",
|
||||
cell: (index) => stationLabel(stations[index].chainage_m, interval),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 구배 행: 구간 중앙에 `L / H / S`, 변화점에 중앙종거를 얹는다.
|
||||
* 도면의 구배 행에 찍히는 소수값(0.56, -0.75 …)이 이 중앙종거다 (|A|·L/8).
|
||||
*/
|
||||
function buildGradeRow(alignment: ProfileAlignment, x: (chainage: number) => number): HTMLElement {
|
||||
const row = element("div", "b05-profile-table__row b05-profile-table__row--grade");
|
||||
row.append(element("span", "b05-profile-table__label", "구배"));
|
||||
alignment.segments.forEach((segment: AlignmentSegment) => {
|
||||
const center = (x(segment.from_m) + x(segment.to_m)) / 2;
|
||||
const span = Math.abs(x(segment.to_m) - x(segment.from_m));
|
||||
if (span < 40) return;
|
||||
const node = element("span", "b05-profile-table__segment");
|
||||
node.append(
|
||||
element("em", "b05-profile-table__segment-line", `L=${segment.length_m.toFixed(2)}m`),
|
||||
element(
|
||||
"em",
|
||||
`b05-profile-table__segment-line is-${segment.height_m >= 0 ? "up" : "down"}`,
|
||||
`H=${segment.height_m.toFixed(2)}m S=${segment.grade_percent.toFixed(2)}%`,
|
||||
),
|
||||
);
|
||||
node.style.left = `${center - span / 2}px`;
|
||||
node.style.width = `${span}px`;
|
||||
if (Math.abs(segment.grade_percent) > alignment.policy.max_grade_pct + 1e-6) {
|
||||
node.classList.add("is-violation");
|
||||
node.title = `종단기울기 ${segment.grade_percent.toFixed(2)}%가 기준 ${alignment.policy.max_grade_pct.toFixed(1)}%를 초과합니다.`;
|
||||
}
|
||||
row.append(node);
|
||||
});
|
||||
alignment.curves.forEach((curve) => {
|
||||
const node = element(
|
||||
"span",
|
||||
"b05-profile-table__ordinate",
|
||||
(curve.delta_pct >= 0 ? "" : "-") + curve.middle_ordinate_m.toFixed(2),
|
||||
);
|
||||
node.title = `중앙종거 (대수차 ${curve.delta_pct.toFixed(2)}% × L ${curve.l_m.toFixed(1)}m / 8)`;
|
||||
placeCell(row, x(curve.chainage_m), node);
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
/** 곡선 행: 변화점마다 `L=` 표기와 R 입력 칸을 둔다 (R을 고치면 L을 역산). */
|
||||
function buildCurveRow(options: ProfileTableOptions): HTMLElement {
|
||||
const { alignment, x, onCurveRadiusChange } = options;
|
||||
const row = element("div", "b05-profile-table__row b05-profile-table__row--curve");
|
||||
row.append(element("span", "b05-profile-table__label", "곡선"));
|
||||
alignment.curves.forEach((curve) => {
|
||||
const cell = element("span", "b05-profile-table__curve");
|
||||
const length = element("em", "b05-profile-table__curve-length", `L=${curve.l_m.toFixed(2)}`);
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.step = "1";
|
||||
input.min = "1";
|
||||
input.className = "b05-profile-table__radius";
|
||||
input.value = curve.r_m.toFixed(1);
|
||||
input.title =
|
||||
`종단곡선 반경 R (m) — 수정하면 L = R × |A| 로 역산합니다.\n` +
|
||||
`K=${curve.k.toFixed(2)} BVC=${curve.bvc_m.toFixed(1)}m EVC=${curve.evc_m.toFixed(1)}m` +
|
||||
(curve.omit_reason ? `\n${curve.omit_reason}` : "");
|
||||
input.addEventListener("change", () => {
|
||||
const parsed = Number.parseFloat(input.value);
|
||||
onCurveRadiusChange(curve, Number.isFinite(parsed) && parsed > 0 ? parsed : null);
|
||||
});
|
||||
if (curve.skip_allowed) cell.classList.add("is-optional");
|
||||
if (curve.omitted) cell.classList.add("is-omitted");
|
||||
cell.append(length, input);
|
||||
placeCell(row, x(curve.chainage_m), cell);
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
export function createProfileTable(options: ProfileTableOptions): HTMLElement {
|
||||
const { alignment, stationInterval, width, x } = options;
|
||||
const table = element("div", "b05-profile-table");
|
||||
table.style.width = `${width}px`;
|
||||
|
||||
table.append(buildGradeRow(alignment, x));
|
||||
buildStationRows(alignment, stationInterval).forEach((spec) => {
|
||||
const row = element(
|
||||
"div",
|
||||
`b05-profile-table__row${spec.modifier ? ` is-${spec.modifier}` : ""}`,
|
||||
);
|
||||
row.append(element("span", "b05-profile-table__label", spec.label));
|
||||
alignment.stations.forEach((station, index) => {
|
||||
const value = spec.cell ? spec.cell(index) : "";
|
||||
if (!value) return;
|
||||
const cell = element("span", "b05-profile-table__cell", value);
|
||||
placeCell(row, x(station.chainage_m), cell);
|
||||
});
|
||||
table.append(row);
|
||||
});
|
||||
table.append(buildCurveRow(options));
|
||||
return table;
|
||||
}
|
||||
@@ -34,10 +34,14 @@
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
/* 하단 종단 패널: 그래프 + 도면 테이블을 담기 위해 화면 높이의 40%를 차지한다. */
|
||||
.b05-route-profile {
|
||||
position: relative;
|
||||
flex: 0 0 250px;
|
||||
min-height: 250px;
|
||||
display: flex;
|
||||
flex: 0 0 40vh;
|
||||
flex: 0 0 40dvh;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: var(--color-surface-raised);
|
||||
@@ -51,13 +55,28 @@
|
||||
|
||||
.b05-route-profile__body {
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding-inline: 15px;
|
||||
}
|
||||
|
||||
.b05-route-profile.is-collapsed .b05-route-profile__body {
|
||||
/* 그래프와 테이블을 같은 폭으로 쌓아 X축이 저절로 맞물리게 한다. */
|
||||
.b05-profile__canvas {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.b05-profile__chart {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.b05-route-profile.is-collapsed .b05-route-profile__body,
|
||||
.b05-route-profile.is-collapsed .b05-route-profile__balance {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -254,6 +273,7 @@
|
||||
/* 종단면도 상단 절·성토 균형 지표 바 */
|
||||
.b05-route-profile__balance {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: nowrap;
|
||||
gap: var(--spacing-16);
|
||||
align-items: center;
|
||||
@@ -284,8 +304,217 @@
|
||||
color: rgb(37 99 235);
|
||||
}
|
||||
|
||||
.b05-route-profile__balance-item.is-over,
|
||||
.b05-route-profile__balance-item.is-unsaved {
|
||||
color: rgb(180 83 9);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.b05-route-profile__balance-item.is-edited em {
|
||||
color: var(--color-royal-amethyst, rgb(109 40 217));
|
||||
}
|
||||
|
||||
.b05-route-profile__balance-warning {
|
||||
overflow: hidden;
|
||||
color: rgb(180 83 9);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.b05-route-profile__balance-reset {
|
||||
flex: 0 0 auto;
|
||||
padding: 1px var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-inputs);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-caption);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ─── 도면 테이블 (구배 ~ 곡선 9행) ───────────────────────────────────────
|
||||
셀은 종단면도와 같은 X 매핑으로 절대 배치되어 측점 수직선과 맞물린다.
|
||||
행 이름표만 sticky로 좌측에 고정되어 가로 스크롤에도 계속 보인다. */
|
||||
.b05-profile-table {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
border-top: 1px solid var(--color-border);
|
||||
color: var(--color-text-body);
|
||||
font-size: 10px;
|
||||
line-height: 1.1;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.b05-profile-table__row {
|
||||
position: relative;
|
||||
height: 16px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b05-profile-table__row--grade {
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.b05-profile-table__row--curve {
|
||||
height: 24px;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.b05-profile-table__label {
|
||||
position: sticky;
|
||||
z-index: 3;
|
||||
left: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
width: 56px;
|
||||
height: 100%;
|
||||
padding-inline: var(--spacing-4);
|
||||
border-right: 1px solid var(--color-border);
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text-muted, var(--color-plum-velvet));
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.b05-profile-table__cell,
|
||||
.b05-profile-table__ordinate,
|
||||
.b05-profile-table__segment,
|
||||
.b05-profile-table__curve {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.b05-profile-table__row.is-cut .b05-profile-table__cell {
|
||||
color: rgb(220 38 38);
|
||||
}
|
||||
|
||||
.b05-profile-table__row.is-fill .b05-profile-table__cell {
|
||||
color: rgb(37 99 235);
|
||||
}
|
||||
|
||||
.b05-profile-table__row.is-plan .b05-profile-table__cell {
|
||||
color: var(--color-royal-amethyst, rgb(109 40 217));
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.b05-profile-table__segment {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
border-left: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
|
||||
border-right: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
|
||||
}
|
||||
|
||||
.b05-profile-table__segment-line {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.b05-profile-table__segment.is-violation {
|
||||
background: color-mix(in srgb, rgb(220 38 38) 12%, transparent);
|
||||
color: rgb(180 83 9);
|
||||
}
|
||||
|
||||
.b05-profile-table__ordinate {
|
||||
z-index: 2;
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text-muted, var(--color-plum-velvet));
|
||||
}
|
||||
|
||||
.b05-profile-table__curve {
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.b05-profile-table__curve.is-optional {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.b05-profile-table__curve.is-omitted {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.b05-profile-table__curve-length {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.b05-profile-table__radius {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 0 1px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 2px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-body);
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ─── 계획고 편집 버튼 (평시 투명, 패널 hover 시 노출) ───────────────────── */
|
||||
.b05-profile-edit {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b05-profile-edit__btn {
|
||||
position: absolute;
|
||||
width: 18px;
|
||||
height: 15px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: transparent;
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
pointer-events: auto;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.b05-route-profile:hover .b05-profile-edit__btn {
|
||||
border-color: color-mix(in srgb, var(--color-border) 60%, transparent);
|
||||
background: color-mix(in srgb, var(--color-surface) 70%, transparent);
|
||||
color: var(--color-text-muted, var(--color-plum-velvet));
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.b05-route-profile .b05-profile-edit__btn:hover,
|
||||
.b05-route-profile .b05-profile-edit__btn:focus-visible {
|
||||
border-color: var(--color-border);
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.b05-profile-edit__btn.is-up {
|
||||
top: 2px;
|
||||
}
|
||||
|
||||
.b05-profile-edit__btn.is-down {
|
||||
bottom: 2px;
|
||||
}
|
||||
|
||||
.b05-profile-edit__btn.is-segment.is-up {
|
||||
top: 19px;
|
||||
}
|
||||
|
||||
.b05-profile-edit__btn.is-segment.is-down {
|
||||
bottom: 19px;
|
||||
}
|
||||
|
||||
.b05-profile-edit__btn.is-reset,
|
||||
.b05-route-profile:hover .b05-profile-edit__btn.is-reset {
|
||||
top: 36px;
|
||||
border-color: color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 45%, transparent);
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-royal-amethyst, rgb(109 40 217));
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@@ -113,6 +113,12 @@ export interface LongitudinalSection {
|
||||
samples: SectionSample[];
|
||||
stations: SectionStation[];
|
||||
design_profiles?: DesignProfile[];
|
||||
/**
|
||||
* 계획선 변화점(PVI) 구조. B05가 편집 기준선으로 사용하며 `design_profiles`는
|
||||
* 여기서 파생된다. 구 데이터에는 없을 수 있어 optional이다.
|
||||
* 구조는 `B05_wf2_Route_UI_Profile_Alignment.ProfileAlignment`.
|
||||
*/
|
||||
profile_alignment?: unknown;
|
||||
}
|
||||
|
||||
export interface CrossSection extends SectionStation {
|
||||
|
||||
@@ -323,6 +323,46 @@ GRADE_TERRAIN_TYPES = ("normal", "special")
|
||||
GRADE_MAIN_DIRECTIONS = ("auto", "ascending", "descending", "none")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# 5-6. 종단 계획선 선형(직선 + 측점 위 종단곡선) 및 편집 정책 (B05 WF2)
|
||||
#
|
||||
# 위 5-5의 FOREST_ROAD_PROFILE_CRITERIA는 법정 기준값이고, 여기는 계획선을
|
||||
# "지반 추종 직선 분할 + 측점 위 종단곡선"으로 만들고 사용자가 0.1m 단위로
|
||||
# 편집하는 동작을 제어하는 실무 정책값이다. 서로 혼용하지 않는다.
|
||||
#
|
||||
# 변화점(PVI)은 반드시 기준 측점 위에만 놓이며, 종단곡선은 그 측점을 중심으로
|
||||
# 대칭 배치되어 곡선 좌우에 직선 구간이 반드시 남는다.
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
FOREST_ROAD_PROFILE_ALIGNMENT = {
|
||||
# 절·성토 균형 허용 오차: |절토 − 성토| / max(절토, 성토) (%)
|
||||
# 5% 미만이면 지반 추종이 왜곡되어 불필요한 변화점이 늘고, 15%를 넘으면
|
||||
# 사토·객토 운반 물량 부담이 커진다. 10%를 실무 균형점으로 둔다.
|
||||
"balance_tolerance_percent": 10.0,
|
||||
# 종단곡선 기본 길이 = 측점간격 × 이 비율 (변화점 대칭 배치).
|
||||
# 측점간격 20m → 곡선 8m가 변화점 좌우 ±4m를 점유하고 나머지 12m는 직선이다.
|
||||
# 곡선반경 R은 길이 L과 기울기 대수차 A로부터 R = L / |A| 로 파생 표기한다.
|
||||
"curve_length_ratio": 0.40,
|
||||
"curve_length_min_m": 2.0,
|
||||
# 인접 직선 길이 대비 곡선 반쪽이 점유할 수 있는 최대 비율.
|
||||
# 0.45면 짧은 쪽 직선의 55%가 항상 직선으로 남아 좌우 곡선이 겹치지 않는다.
|
||||
"curve_tangent_max_ratio": 0.45,
|
||||
# 법정 예외(비포장 & 대수차 5% 이하)를 실제 기하에서도 곡선 생략으로 적용할지.
|
||||
# 규정 다-(3)-(다)는 "두지 않을 수 있다"는 허용 조항이며, 임도 실무 도면은 대수차가
|
||||
# 작아도 변화점을 원곡선으로 처리한다. 기본값 False = 곡선을 항상 삽입하고
|
||||
# 해당 구간에는 "생략 가능" 표시만 남긴다(True로 바꾸면 곡선을 실제로 뺀다).
|
||||
"curve_skip_legal_exception": False,
|
||||
# 변화점(PVI) 추가 페널티 (직선 분할 DP 목적함수, 단위 m²).
|
||||
# 변화점 하나를 늘리려면 잔차제곱합이 이 값 이상 개선되어야 채택된다.
|
||||
"pvi_penalty_m2": 25.0,
|
||||
# 직선 분할 시 한 구간이 가질 수 있는 최소 측점 개수 (짧은 토막 방지)
|
||||
"min_segment_stations": 2,
|
||||
# 사용자 편집 스텝(m). 그래프 상·하단 버튼 1클릭당 계획고 이동량.
|
||||
"edit_step_m": 0.1,
|
||||
# 법정 종단기울기 상한 초과 시 동작: "warn"(경고만) | "block"(편집 차단)
|
||||
"grade_violation_policy": "warn",
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# 6. 저장소 경로
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user