feat(B05): 종단 계획선 전체 측점 폴리라인 전환 + 직선화·쉬프트·undo/redo 신설
2026-09-02 사용자 지시 9건 반영. 화면 실조작 검증은 다음 세션 몫 (사용자 지시로 코딩까지만 진행). 1. 초기 계획선 = 전체 측점 폴리라인 — `design_ground_following_profile()` 신설. 모든 측점을 변화점으로 잡고 계획고 = 원지반고, 라운드는 R을 지정한 자리에만 (`build_curves(only_explicit=)`·`build_alignment(only_explicit_curves=)`). basis `ground_polyline`. 관 정착 선형은 폴백으로 내림. 2. 구간 쉬프트(⬆⬇) 삭제. 3. [직선화] 신설 — `B05_Profile_UI_Profile_Straighten.ts`. 두 측점의 라운드에 탄젠트한 직선으로 대체하고 사이 라운드 삭제. 직선 틸팅 시 가운데 라운드 + 양측 탄젠트 재구성. 4. [쉬프트] 신설 — 직선 구간을 기하에서 되읽어(`detectStraightRun`) 복수 선택, 최외곽 라운드 중심 기준 상·하 평행이동. 5. 방향키 조작 — 상하 0.1m 계획고, 좌우 0.1m 누가거리(구조물·비정규 측점 한정). 6. undo/redo 신설 — B05·B06 조작 세션 키 묶음 스냅샷(`_Profile_History.ts`). 버튼은 요약줄 맨 앞(최대 기울기 좌측), 21x17px. Ctrl+Z / Ctrl+Shift+Z. 7. 종단 요약줄의 횡단배수 최소고 표시 삭제(산식·편집 차단은 유지). 8. [편집 되돌리기] 버튼 삭제 — undo/redo로 대체. 9. 지형 구분 기본값 특수지형 — 패널 셀렉트와 백엔드 기본값(스키마·체인 폴백) 일치. 700줄 제한 — 패널을 `_Profile_Panel_Tools` · `_Profile_Preview` 로 분리하고 측점↔구조물 짝짓기를 `_Profile_Structures` 로 이관(842줄 → 696줄). 검증: tsc --noEmit 오류 0, ruff format/check 통과, pytest tmp/tests/ -q → 366 passed / 14 skipped / 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -382,7 +382,7 @@ async def run_redesign_chain(
|
||||
long_sample_interval_m=params.get("long_sample_interval_m"),
|
||||
**{key: options.get(key) for key in ("grade_class",) if options.get(key)},
|
||||
paved=bool(options.get("paved", False)),
|
||||
terrain_type=str(options.get("terrain_type") or "normal"),
|
||||
terrain_type=str(options.get("terrain_type") or "special"),
|
||||
main_direction=str(options.get("main_direction") or "auto"),
|
||||
min_curve_radius_m=options.get("min_curve_radius_m"),
|
||||
max_uphill_grade=options.get("max_uphill_grade"),
|
||||
|
||||
@@ -55,7 +55,9 @@ class GradeDesignOptions:
|
||||
min_tangent_length_m: float
|
||||
vertical_curve_skip_delta_pct: float
|
||||
design_speed_kph: int
|
||||
terrain_type: str = "normal"
|
||||
# 기본 특수지형(2026-09-02 사용자 지시) — 임도 대상지는 대개 특수지형이다.
|
||||
# 잘못된 값이 들어오면 아래 판정 함수들이 "normal" 로 되눌러 법정 상한을 낮게 잡는다.
|
||||
terrain_type: str = "special"
|
||||
paved: bool = False
|
||||
main_direction: str = "auto"
|
||||
balance_segment_length_m: float | None = None
|
||||
|
||||
@@ -187,6 +187,8 @@ def build_curves(
|
||||
pvi_z: np.ndarray,
|
||||
policy: AlignmentPolicy,
|
||||
curve_radii: dict[str, float] | None = None,
|
||||
*,
|
||||
only_explicit: bool = False,
|
||||
) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
"""각 변화점에 대칭 종단곡선을 삽입하고 곡선 제원을 만든다.
|
||||
|
||||
@@ -195,6 +197,10 @@ def build_curves(
|
||||
|
||||
곡선 반쪽 길이는 짧은 쪽 인접 직선의 `curve_tangent_max_ratio` 이내로 제한해
|
||||
좌우에 직선이 반드시 남게 한다(R을 크게 넣어도 곡선끼리 겹치지 않는다).
|
||||
|
||||
`only_explicit=True` 면 **사용자가 R을 지정한 변화점에만** 곡선을 넣는다. 전체 측점
|
||||
폴리라인(2026-09-02 사용자 확정)은 모든 측점이 변화점이라 기본 곡선을 다 넣으면
|
||||
계획고가 지반고에서 떠 버린다 — 라운드는 [직선화]·틸팅으로 사용자가 만들 때만 생긴다.
|
||||
"""
|
||||
overrides = curve_radii or {}
|
||||
spans = pvi_s[1:] - pvi_s[:-1]
|
||||
@@ -209,6 +215,8 @@ def build_curves(
|
||||
key = chainage_key(chainage)
|
||||
if abs(delta) < 1e-9:
|
||||
continue
|
||||
if only_explicit and overrides.get(key) is None:
|
||||
continue
|
||||
# 법정 다-(3)-(다)는 "종단곡선을 두지 않을 수 있다"는 허용 조항이다.
|
||||
# 실무 도면은 대수차가 작아도 변화점을 원곡선으로 처리하므로, 기본은 곡선을
|
||||
# 삽입하고 생략 가능 구간이라는 표시만 남긴다(config로 실제 생략 전환 가능).
|
||||
@@ -370,8 +378,13 @@ def build_alignment(
|
||||
stations: list[dict[str, Any]],
|
||||
policy: AlignmentPolicy,
|
||||
edits: dict[str, Any] | None = None,
|
||||
only_explicit_curves: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""자동 변화점 + 사용자 편집으로 계획선 선형 전체를 파생한다."""
|
||||
"""자동 변화점 + 사용자 편집으로 계획선 선형 전체를 파생한다.
|
||||
|
||||
`only_explicit_curves=True` 는 전체 측점 폴리라인용 — 사용자가 R을 지정한 변화점에만
|
||||
종단곡선을 넣는다(`build_curves(only_explicit=...)` 와 같은 뜻).
|
||||
"""
|
||||
edits = edits or {}
|
||||
station_offsets = {
|
||||
str(key): float(value)
|
||||
@@ -385,7 +398,9 @@ def build_alignment(
|
||||
}
|
||||
|
||||
pvi_s, pvi_z, sources = resolve_pvi(base_s, base_z, station_offsets)
|
||||
curves, warnings = build_curves(pvi_s, pvi_z, policy, curve_radii)
|
||||
curves, warnings = build_curves(
|
||||
pvi_s, pvi_z, policy, curve_radii, only_explicit=only_explicit_curves
|
||||
)
|
||||
segments = _segments(pvi_s, pvi_z)
|
||||
# 샘플 격자에 변화점(PVI)과 종단곡선 시·종점(BVC/EVC)을 합쳐서 평가한다. 격자만 쓰면
|
||||
# 격자 사이에 놓인 변화점(배관 구조물 자리처럼 임의 chainage에 승격된 점)의 모서리를
|
||||
@@ -452,6 +467,9 @@ def build_alignment(
|
||||
return {
|
||||
"schema_version": ALIGNMENT_SCHEMA_VERSION,
|
||||
"policy": policy.as_dict(),
|
||||
# 화면 사본(`B05_Profile_UI_Profile_Alignment.ts`)이 같은 규칙으로 다시 그리려면
|
||||
# 이 값이 저장본에 남아 있어야 한다(2026-09-02 전체 측점 폴리라인).
|
||||
"only_explicit_curves": bool(only_explicit_curves),
|
||||
"base_pvi": [
|
||||
{"chainage_m": round(float(s), 6), "elevation_m": round(float(z), 6)}
|
||||
for s, z in zip(base_s, base_z)
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
[[B05_Profile_Engine_Grade_Alignment]] 의 기하 파생을 묶어
|
||||
`design_profiles` 배열에 넣을 계획선 한 벌을 만든다.
|
||||
|
||||
세 진입점이 있다.
|
||||
- `design_pipe_anchored_profile()` : **1차(기본)**. 배수유역도가 산출한 배관 배치 측점을
|
||||
네 진입점이 있다.
|
||||
- `design_ground_following_profile()` : **1차(기본, 2026-09-02 사용자 확정)**. 모든 측점을
|
||||
변화점으로 삼아 계획고를 **원지반고 그대로** 두는 폴리라인이다. 종단곡선은 사용자가
|
||||
[직선화]·틸팅으로 만들 때만 생긴다. 관 측점만 정착하던 옛 방식은 관 사이가 길면 골을
|
||||
성토로, 마루를 절토로 메워 최대 성토 +9.36m(용화 실측)가 남았다.
|
||||
- `design_pipe_anchored_profile()` : 관 정착 선형(옛 1차). 배수유역도가 산출한 배관 배치 측점을
|
||||
변화점으로 삼아, 계획선이 각 배관 자리에서 지면선과 만나도록(계획고 = 지반고) 시작점 →
|
||||
배관1 → 배관2 → … → 종점을 직선으로 잇고 기본 R을 얹는다(2026-08-03 사용자 확정).
|
||||
배관(암거)은 계곡 유하부라 계획선이 그 지점에 붙어야 복토·유입 조건이 성립한다.
|
||||
@@ -49,6 +53,8 @@ ALIGNMENT_PROFILE_ID = "design_grade_line"
|
||||
# 폴백으로 떨어진 것을 저장본에서 확인하지 못했다(2026-09-02).
|
||||
PIPE_ANCHORED_BASIS = "pipe_anchored"
|
||||
ALIGNMENT_BASIS = "station_alignment"
|
||||
# 전체 측점 폴리라인(2026-09-02 사용자 확정) — 계획고 = 지반고.
|
||||
GROUND_POLYLINE_BASIS = "ground_polyline"
|
||||
|
||||
|
||||
def infer_station_interval(stations: list[dict[str, Any]]) -> float:
|
||||
@@ -138,6 +144,83 @@ def _clearance_at(
|
||||
return best
|
||||
|
||||
|
||||
def design_ground_following_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]]:
|
||||
"""전체 측점을 변화점으로 삼고 계획고를 원지반고에 맞추는 1차 계획선.
|
||||
|
||||
규칙(2026-09-02 사용자 확정):
|
||||
- **모든 측점이 변화점**이고 그 표고는 그 자리 지반고 그대로다. 절·성토가 거의 0이다.
|
||||
- **종단곡선(라운드)은 자동으로 넣지 않는다.** 사용자가 [직선화]로 만든 직선을
|
||||
틸팅할 때 그 변화점에만 R이 지정되고, 그때 곡선이 생긴다
|
||||
(`build_alignment(only_explicit_curves=True)`).
|
||||
- 기울기는 지형 그대로라 법정 상한을 넘길 수 있다 — 막지 않고 경고만 남긴다
|
||||
(기존 정책과 같다).
|
||||
|
||||
시·종점 오프셋(`start/end_elevation_offset_m`)은 그대로 반영한다. 기본값 0이라
|
||||
평소에는 양끝도 지반고다.
|
||||
"""
|
||||
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)
|
||||
# 측점 목록이 곧 변화점 목록이다. 측점이 비었거나 양끝이 빠져 있으면 종단 격자를 쓴다.
|
||||
node_s = [float(row["chainage_m"]) for row in stations if row.get("chainage_m") is not None]
|
||||
node_s = sorted({round(value, 3) for value in node_s if 0.0 <= value <= total})
|
||||
if len(node_s) < 2:
|
||||
node_s = [round(float(value), 3) for value in chainage]
|
||||
if node_s[0] > 0.0:
|
||||
node_s.insert(0, 0.0)
|
||||
if node_s[-1] < total:
|
||||
node_s.append(round(total, 3))
|
||||
|
||||
base_s = np.array(node_s, dtype=np.float64)
|
||||
base_z = np.interp(base_s, chainage, ground)
|
||||
base_z[0] += options.start_elevation_offset_m
|
||||
base_z[-1] += options.end_elevation_offset_m
|
||||
|
||||
rise = float(base_z[-1] - base_z[0])
|
||||
direction, note = (
|
||||
detect_main_direction(ground, rise)
|
||||
if options.main_direction == "auto"
|
||||
else (options.main_direction, None)
|
||||
)
|
||||
if note:
|
||||
warnings.append(note)
|
||||
|
||||
alignment = build_alignment(
|
||||
base_s=base_s,
|
||||
base_z=base_z,
|
||||
chainage=chainage,
|
||||
ground=ground,
|
||||
stations=stations,
|
||||
policy=policy,
|
||||
edits=dict(edits or {}),
|
||||
only_explicit_curves=True,
|
||||
)
|
||||
warnings.extend(alignment["warnings"])
|
||||
balanced = bool(alignment["balance"]["within_tolerance"])
|
||||
return alignment, _profile_entry(
|
||||
alignment, options, direction, balanced, warnings, GROUND_POLYLINE_BASIS
|
||||
)
|
||||
|
||||
|
||||
def design_pipe_anchored_profile(
|
||||
longitudinal: dict[str, Any],
|
||||
options: GradeDesignOptions,
|
||||
@@ -365,6 +448,11 @@ def rebuild_alignment_profile(
|
||||
base_z = np.array([float(item["elevation_m"]) for item in base], dtype=np.float64)
|
||||
policy = AlignmentPolicy.from_dict(stored.get("policy") or {})
|
||||
|
||||
previous = (longitudinal.get("design_profiles") or [{}])[0]
|
||||
# 전체 측점 폴리라인은 곡선을 자동으로 넣지 않는다 — 재구성도 같은 규칙이어야
|
||||
# 편집 델타를 지웠을 때 초기 폴리라인으로 정확히 돌아간다(2026-09-02).
|
||||
only_explicit = str(previous.get("basis") or "") == GROUND_POLYLINE_BASIS
|
||||
|
||||
alignment = build_alignment(
|
||||
base_s=base_s,
|
||||
base_z=base_z,
|
||||
@@ -373,8 +461,8 @@ def rebuild_alignment_profile(
|
||||
stations=list(longitudinal.get("stations") or []),
|
||||
policy=policy,
|
||||
edits=edits,
|
||||
only_explicit_curves=only_explicit,
|
||||
)
|
||||
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),
|
||||
|
||||
@@ -15,6 +15,7 @@ from typing import Any
|
||||
from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions, design_grade_line
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import (
|
||||
design_alignment_profile,
|
||||
design_ground_following_profile,
|
||||
design_pipe_anchored_profile,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Core import (
|
||||
@@ -214,18 +215,24 @@ def _append_design_profiles(
|
||||
) -> dict[str, Any] | None:
|
||||
"""종단 계획선을 산출해 longitudinal에 붙이고 요약을 반환한다.
|
||||
|
||||
1순위는 **배관 정착 선형** — 배수유역도의 배관 배치 측점마다 계획선이 지면선과
|
||||
만나도록 직선으로 잇고 기본 R을 얹는다(2026-08-03 사용자 확정). 배관이 없거나
|
||||
산출이 불가하면 2순위로 기존 지반 추종 직선 분할 선형(`design_alignment_profile`),
|
||||
그마저 실패하면 구 균형 최적화 계획선으로 폴백하고(편집 불가), 모두 실패해도
|
||||
종횡단 생성 자체는 유지한다.
|
||||
1순위는 **전체 측점 폴리라인**(`design_ground_following_profile`) — 모든 측점의
|
||||
계획고를 원지반고에 맞추고 종단곡선은 넣지 않는다(2026-09-02 사용자 확정).
|
||||
실패하면 2순위 배관 정착 선형(`design_pipe_anchored_profile`, 옛 1차),
|
||||
3순위 지반 추종 직선 분할 선형(`design_alignment_profile`), 그마저 실패하면 구
|
||||
균형 최적화 계획선으로 폴백하고(편집 불가), 모두 실패해도 종횡단 생성 자체는 유지한다.
|
||||
"""
|
||||
longitudinal.setdefault("design_profiles", [])
|
||||
if grade_options is None:
|
||||
return None
|
||||
alignment = None
|
||||
profile = None
|
||||
if pipe_chainages:
|
||||
try:
|
||||
alignment, profile = design_ground_following_profile(
|
||||
longitudinal, grade_options, station_interval_m=station_interval_m
|
||||
)
|
||||
except (ValueError, KeyError, ArithmeticError):
|
||||
logger.exception("B05 전체 측점 폴리라인 산출 실패 — 배관 정착 선형으로 대체")
|
||||
if profile is None and pipe_chainages:
|
||||
try:
|
||||
alignment, profile = design_pipe_anchored_profile(
|
||||
longitudinal,
|
||||
|
||||
@@ -91,7 +91,8 @@ class RouteSolveRequest(BaseModel):
|
||||
long_sample_interval_m: float | None = Field(default=None, gt=0)
|
||||
|
||||
# 종단 계획선(계획고) 설계 옵션. 빈 값은 null로 두어 config 기본값을 쓴다.
|
||||
terrain_type: str = Field(default="normal", description="지형 구분 (normal/special)")
|
||||
# 기본 특수지형(2026-09-02 사용자 지시) — B05 좌측 패널 기본 선택과 같은 값이다.
|
||||
terrain_type: str = Field(default="special", description="지형 구분 (normal/special)")
|
||||
# 설계속도(km/h) — 법정 종단 기준을 정하는 축이다. 임도는 속도를 낼 수 없어 20이
|
||||
# 기본이며, 간선·산불진화만 30·40을 고를 수 있다(2026-08-19 사용자 확정).
|
||||
# 미지정(None)이면 종류별 기본 설계속도(grade_to_design_speed)를 쓴다.
|
||||
|
||||
@@ -398,6 +398,9 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
],
|
||||
});
|
||||
const terrainType = terrainField.select;
|
||||
// 기본값 특수지형(2026-09-02 사용자 지시) — 임도 대상지는 대개 특수지형이라
|
||||
// 매번 바꿔야 했다. 저장분이 있으면 `apply()`가 그 값으로 덮는다.
|
||||
terrainType.value = "special";
|
||||
// 역기울기(5%) 상한 방향은 서버가 지반 형상에서 자동 판정(main_direction="auto")하므로
|
||||
// 수동 선택 UI는 두지 않는다. 노선 균형 구역 길이도 자동 산출 기본값(전체 1구역)에 맡긴다.
|
||||
// 횡단배수 최소고 강제(2026-09-01 사용자 지시) — 기본 해제. 켜면 자동 계획선이 배관
|
||||
|
||||
@@ -112,6 +112,9 @@ export interface AlignmentEdits {
|
||||
export interface ProfileAlignment {
|
||||
schema_version: number;
|
||||
policy: AlignmentPolicy;
|
||||
/** 전체 측점 폴리라인이면 true — 사용자가 R을 지정한 변화점에만 라운드를 넣는다
|
||||
* (2026-09-02 사용자 확정, 백엔드 `build_alignment(only_explicit_curves=)` 와 같은 값). */
|
||||
only_explicit_curves?: boolean;
|
||||
base_pvi: AlignmentNode[];
|
||||
edits: AlignmentEdits;
|
||||
pvi: AlignmentPvi[];
|
||||
@@ -127,6 +130,8 @@ export interface ProfileAlignment {
|
||||
/** 자동 선형과 지반 종단 — 편집을 얹기 위한 고정 입력. */
|
||||
export interface AlignmentBase {
|
||||
policy: AlignmentPolicy;
|
||||
/** 라운드를 사용자가 지정한 변화점에만 넣을지 — 저장본에서 그대로 물려받는다. */
|
||||
onlyExplicitCurves: boolean;
|
||||
basePvi: AlignmentNode[];
|
||||
chainage: number[];
|
||||
ground: number[];
|
||||
@@ -162,6 +167,7 @@ function interpolate(xs: number[], ys: number[], value: number): number {
|
||||
export function toAlignmentBase(alignment: ProfileAlignment): AlignmentBase {
|
||||
return {
|
||||
policy: alignment.policy,
|
||||
onlyExplicitCurves: alignment.only_explicit_curves === true,
|
||||
basePvi: alignment.base_pvi.map((node) => ({ ...node })),
|
||||
chainage: alignment.samples.map((sample) => sample.chainage_m),
|
||||
ground: alignment.samples.map((sample) => sample.ground_elevation_m),
|
||||
@@ -207,6 +213,7 @@ function buildCurves(
|
||||
policy: AlignmentPolicy,
|
||||
curveRadii: Record<string, number>,
|
||||
warnings: string[],
|
||||
onlyExplicit = false,
|
||||
): WorkingCurve[] {
|
||||
const curves: WorkingCurve[] = [];
|
||||
const skipDelta = policy.curve_skip_delta_pct / 100;
|
||||
@@ -225,6 +232,8 @@ function buildCurves(
|
||||
// 기본 L이 없는 옛 저장분만 R 기준으로 되돌아간다.
|
||||
const requested = curveRadii[key];
|
||||
const hasRequested = Number.isFinite(requested) && requested > 0;
|
||||
// 전체 측점 폴리라인 — 사용자가 R을 지정한 변화점에만 라운드가 생긴다.
|
||||
if (onlyExplicit && !hasRequested) continue;
|
||||
const fallbackLength = Number.isFinite(policy.default_curve_length_m)
|
||||
? (policy.default_curve_length_m as number)
|
||||
: policy.default_curve_radius_m * Math.abs(delta);
|
||||
@@ -309,7 +318,13 @@ export function buildAlignment(base: AlignmentBase, input: AlignmentEdits): Prof
|
||||
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_radii, warnings);
|
||||
const curves = buildCurves(
|
||||
nodes,
|
||||
base.policy,
|
||||
edits.curve_radii,
|
||||
warnings,
|
||||
base.onlyExplicitCurves === true,
|
||||
);
|
||||
|
||||
const segments: AlignmentSegment[] = [];
|
||||
for (let index = 0; index < nodes.length - 1; index += 1) {
|
||||
@@ -411,6 +426,7 @@ export function buildAlignment(base: AlignmentBase, input: AlignmentEdits): Prof
|
||||
return {
|
||||
schema_version: 1,
|
||||
policy: base.policy,
|
||||
only_explicit_curves: base.onlyExplicitCurves === true,
|
||||
base_pvi: base.basePvi,
|
||||
edits,
|
||||
pvi: pviRows,
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment";
|
||||
import { minCoverWarningText, type MinCoverViolation } from "./B05_Profile_UI_Profile_MinCover";
|
||||
import type { MinCoverViolation } from "./B05_Profile_UI_Profile_MinCover";
|
||||
|
||||
export interface BalanceBarParams {
|
||||
/** 표시줄 컨테이너. 그릴 때마다 통째로 갈아 끼운다. */
|
||||
@@ -30,6 +30,8 @@ export interface BalanceBarParams {
|
||||
hasIrregularStations: boolean;
|
||||
/** 저장되지 않은 편집이 있는지. */
|
||||
dirty: boolean;
|
||||
/** 요약줄 맨 앞에 놓는 도구줄(되돌리기·직선화·쉬프트·틸팅) — 2026-09-02. */
|
||||
tools?: HTMLElement;
|
||||
/** 횡단배수 최소 계획고 위반(2026-08-23) — 배수관·BOX암거 토피 미확보 경고. */
|
||||
minCoverViolations?: MinCoverViolation[];
|
||||
/** [초기선 복원] — 편집·비정규 측점을 모두 지운다. */
|
||||
@@ -38,6 +40,8 @@ export interface BalanceBarParams {
|
||||
|
||||
export function renderBalanceBar(params: BalanceBarParams): void {
|
||||
params.balanceBar.replaceChildren();
|
||||
// 도구줄은 계획선이 없을 때도 둔다 — 되돌리기는 계획선 밖 조작(구조물 등)도 되돌린다.
|
||||
if (params.tools) params.balanceBar.append(params.tools);
|
||||
if (!params.alignment) {
|
||||
if (params.legacyAlignment) {
|
||||
const note = document.createElement("span");
|
||||
@@ -74,10 +78,8 @@ export function renderBalanceBar(params: BalanceBarParams): void {
|
||||
];
|
||||
// 필요한 곳이 없으면 적지 않는다 — "0곳"은 화면 폭만 먹는다.
|
||||
if (curvesNeeded) entries.push(["종단곡선 필요", `${curvesNeeded} 곳`, "over"]);
|
||||
// 횡단배수 최소 계획고(2026-08-23 사용자 지시) — 관경·구체높이 + 토피 0.5m를
|
||||
// 밑도는 측점이 있으면 경고한다. 계획선을 대신 올려 주지는 않는다(사용자 판단).
|
||||
const coverWarning = minCoverWarningText(params.minCoverViolations ?? []);
|
||||
if (coverWarning) entries.push(["횡단배수 최소고", coverWarning, "over"]);
|
||||
// 횡단배수 최소고 표시는 2026-09-02 사용자 지시로 삭제했다. 편집 차단(강제)은
|
||||
// `B05_Profile_UI_Profile_Render.ts` 에 그대로 남아 있고 기본 해제다.
|
||||
const editedCount = Object.keys(alignment.edits.station_offsets).length;
|
||||
if (editedCount) entries.push(["편집 측점", `${editedCount} 개`, "edited"]);
|
||||
entries.forEach(([label, value, tone]) => {
|
||||
@@ -88,14 +90,6 @@ export function renderBalanceBar(params: BalanceBarParams): void {
|
||||
item.append(caption, document.createTextNode(value));
|
||||
// 상한 초과 구간의 내역은 별도 경고 칩 대신 이 항목의 툴팁으로 붙인다 —
|
||||
// 같은 사실을 두 번 적지 않는다(2026-08-19 재편).
|
||||
if (label === "횡단배수 최소고" && params.minCoverViolations?.length) {
|
||||
item.title = params.minCoverViolations
|
||||
.map(
|
||||
(entry) =>
|
||||
`${entry.chainage_m.toFixed(1)}m ${entry.label}: 계획고 ${entry.planned_m.toFixed(2)} < 최소 ${entry.required_m.toFixed(2)} (부족 ${entry.shortfall_m.toFixed(2)}m)`,
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
if (label === "최대 기울기" && violations.length) {
|
||||
item.title = violations
|
||||
.map(
|
||||
@@ -106,25 +100,8 @@ export function renderBalanceBar(params: BalanceBarParams): void {
|
||||
}
|
||||
params.balanceBar.append(item);
|
||||
});
|
||||
if (params.edited || params.hasIrregularStations) {
|
||||
const reset = document.createElement("button");
|
||||
reset.type = "button";
|
||||
reset.className = "b05-route-profile__balance-reset";
|
||||
// 이 버튼이 지우는 것은 **화면에 쌓인 편집 델타**다(계획선 변화점 이동·비정규
|
||||
// 측점). [저장]을 거치면 그 편집이 정본에 반영되므로 결과적으로 "마지막 저장
|
||||
// 상태"가 되는 것뿐, 저장 지점으로 되돌아가는 기능이 아니다. 최초 자동 계산
|
||||
// 상태로의 복귀는 좌측 하단 [초기화](B05·B06 재계산)가 맡는다(2026-08-19 정정).
|
||||
// 자리도 값 칩들 뒤 — 편집 상태(미저장 배지) 옆이 맥락에 맞다.
|
||||
reset.textContent = "편집 되돌리기";
|
||||
reset.title =
|
||||
"화면에서 수정한 계획선 변화점과 추가한 비정규 측점을 지웁니다.\n" +
|
||||
"이미 저장한 내용은 정본에 반영돼 있어 그대로 남습니다.\n" +
|
||||
"최초 자동 계산 상태로 되돌리려면 좌측 하단 [초기화]를 쓰세요.";
|
||||
reset.addEventListener("click", () => {
|
||||
params.onResetAll();
|
||||
});
|
||||
params.balanceBar.append(reset);
|
||||
}
|
||||
// [편집 되돌리기] 버튼은 2026-09-02 사용자 지시로 삭제했다 — 한 단계씩 되돌리는
|
||||
// undo/redo가 대신하며, 최초 자동 계산 상태 복귀는 좌측 하단 [초기화]가 맡는다.
|
||||
if (params.dirty) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "b05-route-profile__balance-item is-unsaved";
|
||||
|
||||
@@ -7,19 +7,15 @@
|
||||
*
|
||||
* 버튼 구성 (평시 투명, 패널 hover 시 노출):
|
||||
* - 측점 ▲ / ▼ : 그 측점을 변화점으로 승격시켜 계획고를 ±step 만큼 꺾는다.
|
||||
* - 구간 ⇧ / ⇩ : 직선 구간 전체를 평행이동한다(구배 유지, 양 끝 변화점 동시 이동).
|
||||
* - 원복 ↺ : 그 측점의 편집 델타만 지워 자동 선형으로 되돌린다.
|
||||
*
|
||||
* 측점 버튼과 구간 버튼은 **그래프 위·아래 같은 줄**에 놓는다. 안쪽 줄에 두면 X축 제목과
|
||||
* 측점 라벨에 가려 보이지 않기 때문이다. 자리가 겹칠 땐 측점 버튼을 수직선 위에 고정하고
|
||||
* 구간 버튼만 옆으로 비킨다.
|
||||
* 구간 ⇧⇩(평행이동)과 원복 ↺ 는 2026-09-02 사용자 지시로 삭제했다 — 구간 조작은
|
||||
* [직선화]+[쉬프트]가, 되돌리기는 undo/redo가 대신한다.
|
||||
*
|
||||
* 측점 버튼은 **그래프 위·아래 같은 줄**에 놓는다. 안쪽 줄에 두면 X축 제목과 측점
|
||||
* 라벨에 가려 보이지 않기 때문이다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
AlignmentEdits,
|
||||
AlignmentSegment,
|
||||
ProfileAlignment,
|
||||
} from "./B05_Profile_UI_Profile_Alignment";
|
||||
import type { AlignmentEdits, ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment";
|
||||
import { chainageKey, emptyEdits, hasEdits } from "./B05_Profile_UI_Profile_Alignment";
|
||||
|
||||
const DRAFT_KEY_PREFIX = "b05-profile-alignment-draft";
|
||||
@@ -173,15 +169,9 @@ export interface EditOverlayOptions {
|
||||
width: number;
|
||||
x: (chainageM: number) => number;
|
||||
step: number;
|
||||
/** 규칙 격자 밖 비정규 측점(구조물). 규칙 측점과 똑같이 ▲/▼(+원복) 버튼을 단다. */
|
||||
/** 규칙 격자 밖 비정규 측점(구조물). 규칙 측점과 똑같이 ▲/▼ 버튼을 단다. */
|
||||
irregularStations?: Array<{ chainage_m: number }>;
|
||||
onStation: (chainageM: number, delta: number) => void;
|
||||
onSegment: (segment: AlignmentSegment, delta: number) => void;
|
||||
onResetStation: (chainageM: number) => void;
|
||||
/** 구간 평행이동 편집을 자동 선형으로 원복(양 끝 오프셋 삭제). */
|
||||
onResetSegment: (segment: AlignmentSegment) => void;
|
||||
/** 쉬프트 가능 구간 판정 — false면 그 구간의 ⬆⬇를 만들지 않는다(힌지 부족). */
|
||||
canShift?: (segment: AlignmentSegment) => boolean;
|
||||
}
|
||||
|
||||
function overlayButton(
|
||||
@@ -235,8 +225,7 @@ function planElevationAtSample(alignment: ProfileAlignment, chainageM: number):
|
||||
|
||||
/** 그래프 영역 위에 겹치는 편집 버튼 층을 만든다 (선 자체는 가리지 않는다). */
|
||||
export function createEditOverlay(options: EditOverlayOptions): HTMLElement {
|
||||
const { alignment, width, x, step, onStation, onSegment, onResetStation, onResetSegment } =
|
||||
options;
|
||||
const { alignment, width, x, step, onStation } = options;
|
||||
const irregular = options.irregularStations ?? [];
|
||||
const layer = document.createElement("div");
|
||||
layer.className = "b05-profile-edit";
|
||||
@@ -281,31 +270,6 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement {
|
||||
};
|
||||
})(),
|
||||
);
|
||||
const stationXs = stationPlacements.map((entry) => entry.left);
|
||||
|
||||
/** 이미 자리를 잡은 구간 버튼 x — 구간끼리도 겹치지 않게 기억해 둔다. */
|
||||
const segmentXs: number[] = [];
|
||||
|
||||
/**
|
||||
* 구간 버튼을 측점 버튼과 같은 행에 두되, 겹치는 자리면 옆으로 비킨다.
|
||||
* 측점 버튼은 측점 수직선 위에 있어야 의미가 통하므로 **측점 쪽을 고정**하고
|
||||
* 구간 버튼만 오른쪽으로 밀어낸다(구간이 짝수 개 측점을 걸치면 중점이 측점과 겹친다).
|
||||
*
|
||||
* 한 번만 밀면 밀어낸 자리에 또 다른 측점·구간 버튼이 있을 때 그대로 겹친다
|
||||
* (2026-08-17 사용자 보고). 빈자리가 나올 때까지 반복해서 밀어낸다.
|
||||
*/
|
||||
function avoidStations(center: number): number {
|
||||
let placed = center;
|
||||
for (let guard = 0; guard < 40; guard += 1) {
|
||||
const blocking = [...stationXs, ...segmentXs]
|
||||
.filter((px) => Math.abs(px - placed) < BUTTON_CLEARANCE_PX)
|
||||
.sort((left, right) => right - left)[0];
|
||||
if (blocking === undefined) break;
|
||||
placed = blocking + BUTTON_CLEARANCE_PX;
|
||||
}
|
||||
segmentXs.push(placed);
|
||||
return placed;
|
||||
}
|
||||
// 측점 하나에 ▲/▼(+편집됐으면 원복 ↺) 버튼을 단다. 규칙·비정규 측점 공용 — 비정규 측점도
|
||||
// `onStation`이 임의 chainage를 변화점으로 승격시키므로 규칙 측점과 완전히 같은 파이프라인이다.
|
||||
// left는 겹침 회피가 끝난 화면 x — 측점 수직선(x(chainage))과 다를 수 있다.
|
||||
@@ -327,60 +291,14 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement {
|
||||
down.style.left = `${left - BUTTON_HALF_PX}px`;
|
||||
layer.append(up, down);
|
||||
|
||||
if (!isEdited) return;
|
||||
const offset = alignment.edits.station_offsets[chainageKey(chainageM)];
|
||||
const reset = overlayButton(
|
||||
repeater,
|
||||
"is-reset",
|
||||
"↺",
|
||||
`${label} — 자동 선형으로 원복 (현재 ${offset >= 0 ? "+" : ""}${offset.toFixed(2)}m)`,
|
||||
() => onResetStation(chainageM),
|
||||
);
|
||||
reset.style.left = `${left - BUTTON_HALF_PX}px`;
|
||||
layer.append(reset);
|
||||
// 측점 원복 ↺ 는 2026-09-02 사용자 지시로 삭제했다 — undo/redo 로 대체한다.
|
||||
void isEdited;
|
||||
}
|
||||
|
||||
stationPlacements.forEach((entry) => addStationButtons(entry.chainage, entry.plan, entry.left));
|
||||
|
||||
alignment.segments.forEach((segment) => {
|
||||
const left = x(segment.from_m);
|
||||
const right = x(segment.to_m);
|
||||
if (right - left < 36) return;
|
||||
// 힌지(안쪽 미틸트 측점 2개)를 못 만드는 구간은 쉬프트 대상이 아니다(2026-08-23).
|
||||
if (options.canShift && !options.canShift(segment)) return;
|
||||
const center = avoidStations((left + right) / 2);
|
||||
const label =
|
||||
`구간 ${segment.from_m.toFixed(0)}~${segment.to_m.toFixed(0)}m ` +
|
||||
`(구배 ${segment.grade_percent.toFixed(2)}%) 전체 평행이동`;
|
||||
// 구간 이동 화살표는 속이 찬 글리프(⬆⬇)를 쓴다(2026-08-04 사용자 지시 — ⇧⇩는 윤곽선뿐이라
|
||||
// 흐릿했다). ︎(텍스트 표기 선택자)로 이모지 컬러 렌더링을 막아 방향색이 살게 한다.
|
||||
const up = overlayButton(repeater, "is-segment is-up", "⬆︎", `${label} — ${step}m 올림`, () =>
|
||||
onSegment(segment, step),
|
||||
);
|
||||
up.style.left = `${center - BUTTON_HALF_PX}px`;
|
||||
const down = overlayButton(
|
||||
repeater,
|
||||
"is-segment is-down",
|
||||
"⬇︎",
|
||||
`${label} — ${step}m 내림`,
|
||||
() => onSegment(segment, -step),
|
||||
);
|
||||
down.style.left = `${center - BUTTON_HALF_PX}px`;
|
||||
layer.append(up, down);
|
||||
|
||||
// 구간 양 끝 중 하나라도 편집됐으면 원복 ↺ 노출(측점 ↺와 동일 스타일·연산).
|
||||
if (edited.has(chainageKey(segment.from_m)) || edited.has(chainageKey(segment.to_m))) {
|
||||
const reset = overlayButton(
|
||||
repeater,
|
||||
"is-segment is-reset",
|
||||
"↺",
|
||||
`${label} — 자동 선형으로 원복`,
|
||||
() => onResetSegment(segment),
|
||||
);
|
||||
reset.style.left = `${center - BUTTON_HALF_PX}px`;
|
||||
layer.append(reset);
|
||||
}
|
||||
});
|
||||
// 구간 평행이동(⬆⬇)과 구간 원복 ↺ 도 같은 지시로 삭제했다. 구간 단위 조작은
|
||||
// [직선화]로 만든 직선을 [쉬프트]로 옮기는 흐름이 대신한다.
|
||||
|
||||
return layer;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_History.ts
|
||||
* B05 조작 되돌리기(undo)·다시하기(redo) — 세션 캐시 묶음 스냅샷.
|
||||
*
|
||||
* 왜 세션 캐시 통째인가(2026-09-02 사용자 지시): B05의 조작값은 화면마다 흩어진 것이
|
||||
* 아니라 **세션 저장소 한 벌**로 모인다(계획선 편집 초안 · 구조물 대기분 · 상단측
|
||||
* 오버라이드 · 최신 경로 캐시). 종단 계획선을 건드리면 구조물 자리와 횡단 설계 캐시가
|
||||
* 함께 바뀌므로, 종단 편집만 되돌리면 나머지가 어긋난 채 남는다. 그래서 되돌리기 단위는
|
||||
* **B05 조작 전부**이고, 스냅샷도 세션 키 묶음으로 뜬다.
|
||||
*
|
||||
* 저장하지 않는 것: 패널 접힘·높이·표시 토글 같은 **화면 배치 값**. 조작 이력이 아니라
|
||||
* 보기 설정이라 되돌릴 대상이 아니다(`isLayoutKey`).
|
||||
* ========================================================================== */
|
||||
|
||||
/** 조작값 스냅샷 대상 세션 키 접두어. B05·B06은 한 페이지라 함께 뜬다(CLAUDE.md 5장). */
|
||||
const DATA_KEY_PREFIXES = ["b05:", "b05-", "b06:", "b06-"];
|
||||
|
||||
/** 조작이 아니라 보기 설정인 키 — 스냅샷에서 뺀다. */
|
||||
function isLayoutKey(key: string): boolean {
|
||||
return (
|
||||
key.includes("collapsed") ||
|
||||
key.includes("width") ||
|
||||
key.includes("height") ||
|
||||
key.includes("open") ||
|
||||
key.includes("visible")
|
||||
);
|
||||
}
|
||||
|
||||
function isDataKey(key: string): boolean {
|
||||
return DATA_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)) && !isLayoutKey(key);
|
||||
}
|
||||
|
||||
/** 한 시점의 조작값 — 키 → 값(JSON 문자열). */
|
||||
export type HistorySnapshot = Record<string, string>;
|
||||
|
||||
function takeSnapshot(): HistorySnapshot {
|
||||
const snapshot: HistorySnapshot = {};
|
||||
try {
|
||||
for (let index = 0; index < sessionStorage.length; index += 1) {
|
||||
const key = sessionStorage.key(index);
|
||||
if (!key || !isDataKey(key)) continue;
|
||||
const value = sessionStorage.getItem(key);
|
||||
if (value !== null) snapshot[key] = value;
|
||||
}
|
||||
} catch {
|
||||
// 세션 저장소를 못 쓰는 환경에서는 되돌리기만 비활성이 된다.
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function sameSnapshot(left: HistorySnapshot, right: HistorySnapshot): boolean {
|
||||
const leftKeys = Object.keys(left);
|
||||
const rightKeys = Object.keys(right);
|
||||
if (leftKeys.length !== rightKeys.length) return false;
|
||||
return leftKeys.every((key) => left[key] === right[key]);
|
||||
}
|
||||
|
||||
/** 스냅샷을 세션에 되쓴다 — 스냅샷에 없던 조작 키는 지운다. */
|
||||
function restoreSnapshot(snapshot: HistorySnapshot): void {
|
||||
try {
|
||||
const existing: string[] = [];
|
||||
for (let index = 0; index < sessionStorage.length; index += 1) {
|
||||
const key = sessionStorage.key(index);
|
||||
if (key && isDataKey(key)) existing.push(key);
|
||||
}
|
||||
existing.forEach((key) => {
|
||||
if (!(key in snapshot)) sessionStorage.removeItem(key);
|
||||
});
|
||||
Object.entries(snapshot).forEach(([key, value]) => sessionStorage.setItem(key, value));
|
||||
} catch {
|
||||
// 되쓰기 실패 시 화면 상태는 그대로 둔다 — 잘못 섞인 복원보다 낫다.
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProfileHistory {
|
||||
/** 조작이 끝난 뒤 현재 상태를 이력에 쌓는다(직전과 같으면 무시). */
|
||||
record(): void;
|
||||
undo(): boolean;
|
||||
redo(): boolean;
|
||||
canUndo(): boolean;
|
||||
canRedo(): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 되돌리기 이력을 만든다.
|
||||
*
|
||||
* `onRestore`는 세션 값이 바뀐 뒤 화면을 다시 세우는 콜백이다 — 세션이 정본이므로
|
||||
* 여기서 각 패널이 자기 값을 다시 읽어 그린다.
|
||||
*/
|
||||
export function createProfileHistory(onRestore: () => void, limit = 50): ProfileHistory {
|
||||
const stack: HistorySnapshot[] = [takeSnapshot()];
|
||||
let cursor = 0;
|
||||
/** 복원 중에 들어오는 record()를 무시한다 — 복원이 새 이력을 만들면 redo가 사라진다. */
|
||||
let restoring = false;
|
||||
|
||||
function apply(index: number): boolean {
|
||||
if (index < 0 || index >= stack.length) return false;
|
||||
cursor = index;
|
||||
restoring = true;
|
||||
restoreSnapshot(stack[cursor]);
|
||||
try {
|
||||
onRestore();
|
||||
} finally {
|
||||
restoring = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
record() {
|
||||
if (restoring) return;
|
||||
const snapshot = takeSnapshot();
|
||||
if (sameSnapshot(snapshot, stack[cursor])) return;
|
||||
stack.splice(cursor + 1);
|
||||
stack.push(snapshot);
|
||||
if (stack.length > limit) stack.shift();
|
||||
cursor = stack.length - 1;
|
||||
},
|
||||
undo: () => apply(cursor - 1),
|
||||
redo: () => apply(cursor + 1),
|
||||
canUndo: () => cursor > 0,
|
||||
canRedo: () => cursor < stack.length - 1,
|
||||
};
|
||||
}
|
||||
@@ -15,11 +15,7 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
|
||||
import { createPanelResizer } from "@ui/ui_template_resizer";
|
||||
import { createDrainagePanel } from "./B05_Profile_UI_Drainage_Panel";
|
||||
import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import {
|
||||
structureAnchorM,
|
||||
type StructureInstance,
|
||||
type StructureType,
|
||||
} from "./B05_Profile_Api_Structures";
|
||||
import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures";
|
||||
import {
|
||||
createProfileTableOverlay,
|
||||
TABLE_OVERLAY_MIN_HEIGHT,
|
||||
@@ -28,7 +24,6 @@ import { hasLegacyAlignment, readAlignment } from "./B05_Profile_UI_Profile_Data
|
||||
import { createProgressCircle } from "@ui/ui_template_progress";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { saveProfileAlignment } from "./B05_Profile_Api_Fetch";
|
||||
import { previewCrossDesigns } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import { staleDesignChainages } from "../B06_Section/B06_Section_UI_Section_Common";
|
||||
import {
|
||||
findMinCoverViolations,
|
||||
@@ -58,6 +53,12 @@ import { renderBalanceBar } from "./B05_Profile_UI_Profile_Balance";
|
||||
import { createHeightCascade } from "./B05_Profile_UI_Profile_Heights";
|
||||
import { renderProfile } from "./B05_Profile_UI_Profile_Render";
|
||||
import { irregularStationId, type IrregularStation } from "./B05_Profile_UI_IrregularStations";
|
||||
import { createCrossPreview } from "./B05_Profile_UI_Profile_Preview";
|
||||
import { createPanelTools } from "./B05_Profile_UI_Profile_Panel_Tools";
|
||||
import {
|
||||
stationIdAtStructure as stationIdAtStructureOf,
|
||||
structureIdAtStation as structureIdAtStationOf,
|
||||
} from "./B05_Profile_UI_Profile_Structures";
|
||||
import "../B06_Section/B06_Section_UI_Style.css";
|
||||
// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때
|
||||
// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인.
|
||||
@@ -300,11 +301,10 @@ export function createRouteProfilePanel(
|
||||
let base: AlignmentBase | null = null;
|
||||
let alignment: ProfileAlignment | null = null;
|
||||
let store = createProfileEditStore(null, emptyEdits(), () => rebuild());
|
||||
/** 서버 저장분 — 되돌리기 복원이 초안을 다시 읽을 때 기준으로 쓴다. */
|
||||
let savedEdits: AlignmentEdits = emptyEdits();
|
||||
let resizeTimer = 0;
|
||||
let redrawPending = false;
|
||||
/** 횡단 설계 프리뷰 디바운스 타이머와 최신 요청 번호(늦게 온 응답 버리기용). */
|
||||
let crossPreviewTimer = 0;
|
||||
let crossPreviewSeq = 0;
|
||||
let lastWidth = 0;
|
||||
let lastHeight = 0;
|
||||
|
||||
@@ -312,33 +312,11 @@ export function createRouteProfilePanel(
|
||||
* 측점 선택 — 같은 측점 재선택이면 해제한다(2026-08-04 사용자 지시).
|
||||
* 그래프(종단·유토곡선) 클릭이 모두 이 경로를 탄다. 해제는 null로 Page에 알린다.
|
||||
*/
|
||||
/** 같은 자리로 볼 여유(m) — 측점선과 알약은 같은 누가거리를 쓰지만 소수점이 갈린다. */
|
||||
const SAME_CHAINAGE_M = 0.51;
|
||||
|
||||
/** 측점선 id → 알약(구조물) id. 같은 누가거리의 구조물이 없으면 null. */
|
||||
function structureIdAtStation(stationId: string | null): string | null {
|
||||
if (stationId === null) return null;
|
||||
const prefix = irregularStationId("");
|
||||
if (!stationId.startsWith(prefix)) return null;
|
||||
const station = irregularStations.find((entry) => irregularStationId(entry.id) === stationId);
|
||||
if (!station) return null;
|
||||
const hit = structures.find(
|
||||
(item) => Math.abs(structureAnchorM(item) - station.chainage_m) < SAME_CHAINAGE_M,
|
||||
);
|
||||
return hit?.structure_id ?? null;
|
||||
}
|
||||
|
||||
/** 알약(구조물) id → 측점선 id. 세로선이 없는 구조물(A군 외)이면 null. */
|
||||
function stationIdAtStructure(structureId: string | null): string | null {
|
||||
if (structureId === null) return null;
|
||||
const structure = structures.find((item) => item.structure_id === structureId);
|
||||
if (!structure) return null;
|
||||
const anchor = structureAnchorM(structure);
|
||||
const station = irregularStations.find(
|
||||
(entry) => Math.abs(entry.chainage_m - anchor) < SAME_CHAINAGE_M,
|
||||
);
|
||||
return station ? irregularStationId(station.id) : null;
|
||||
}
|
||||
/** 측점선 ↔ 알약(구조물) 짝짓기는 `_Profile_Structures` 로 옮겼다(700줄 한계). */
|
||||
const structureIdAtStation = (stationId: string | null): string | null =>
|
||||
structureIdAtStationOf(stationId, irregularStations, structures);
|
||||
const stationIdAtStructure = (structureId: string | null): string | null =>
|
||||
stationIdAtStructureOf(structureId, irregularStations, structures);
|
||||
|
||||
/**
|
||||
* 측점 선택 — 같은 측점 재선택이면 해제한다(2026-08-04 사용자 지시).
|
||||
@@ -377,6 +355,7 @@ export function createRouteProfilePanel(
|
||||
renderBalanceBar({
|
||||
minCoverViolations,
|
||||
balanceBar,
|
||||
tools: tools.render(),
|
||||
alignment,
|
||||
legacyAlignment: !!detail && hasLegacyAlignment(detail.longitudinal),
|
||||
edited: store.edited(),
|
||||
@@ -389,6 +368,26 @@ export function createRouteProfilePanel(
|
||||
});
|
||||
}
|
||||
|
||||
/* [직선화]·[쉬프트]·되돌리기·방향키 배선은 `_Panel_Tools` 로 뺐다(700줄 한계). */
|
||||
const { tools, history, handleToolPick } = createPanelTools({
|
||||
root,
|
||||
base: () => base,
|
||||
alignment: () => alignment,
|
||||
edits: () => store.edits(),
|
||||
applyEdits: (next) => applyEdits(next),
|
||||
selectedStationId: () => selectedStationId,
|
||||
irregularStations: () => irregularStations,
|
||||
stationIdOf: (station) => irregularStationId(station.id),
|
||||
moveStation: (station, toChainageM) =>
|
||||
callbacks?.onStructureMove?.(station.chainage_m, toChainageM, station),
|
||||
restore: () => {
|
||||
// 세션이 정본이므로 편집 초안을 다시 읽어 그린다.
|
||||
store = createProfileEditStore(routeId, savedEdits, () => rebuild());
|
||||
rebuild();
|
||||
},
|
||||
refresh: () => renderBalance(),
|
||||
});
|
||||
|
||||
/** 편집을 적용한다. 법정 위반 정책이 block이면 새 위반이 생기는 편집을 막는다. */
|
||||
function applyEdits(next: AlignmentEdits): void {
|
||||
if (!base || !alignment) return;
|
||||
@@ -404,6 +403,7 @@ export function createRouteProfilePanel(
|
||||
return;
|
||||
}
|
||||
store.replace(next);
|
||||
history.record();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -424,48 +424,19 @@ export function createRouteProfilePanel(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 횡단 설계 프리뷰 요청 — 끌기 중에는 계속 호출되므로 마지막 값만 보낸다.
|
||||
* 응답이 늦게 와도 그 사이 편집이 더 있었으면 버린다(seq 비교).
|
||||
*/
|
||||
function scheduleCrossPreview(): void {
|
||||
if (!detail || routeId === null) return;
|
||||
window.clearTimeout(crossPreviewTimer);
|
||||
crossPreviewTimer = window.setTimeout(() => {
|
||||
if (!detail || routeId === null) return;
|
||||
const seq = (crossPreviewSeq += 1);
|
||||
const targetRouteId = routeId;
|
||||
// full_designs — 설계선 좌표까지 통째로 받아야 3D 코리도가 편집 즉시 정확한
|
||||
// 형상으로 재빌드된다(2026-08-23 사용자 지시). 암 경계는 백엔드가 세션값 없으면
|
||||
// DB 저장 echo를 폴백으로 쓰므로 그대로 유지된다.
|
||||
void previewCrossDesigns(projectId, targetRouteId, store.edits(), undefined, {
|
||||
fullDesigns: true,
|
||||
})
|
||||
.then((next) => {
|
||||
if (seq !== crossPreviewSeq || !detail || routeId !== targetRouteId) return;
|
||||
// 공유 캐시가 들고 있는 **같은 객체**를 제자리 갱신한다 — B06이 이 객체를 그대로
|
||||
// 보므로 페이지를 넘어가도 다시 받을 필요가 없다. 지반선 샘플은 건드리지 않는다.
|
||||
const designByChainage = new Map(
|
||||
next.designs.map((entry) => [entry.chainage_m.toFixed(3), entry.design]),
|
||||
);
|
||||
for (const section of detail.cross_sections) {
|
||||
const full = designByChainage.get(section.chainage_m.toFixed(3));
|
||||
if (!full || !section.design) continue;
|
||||
// 전체 교체(설계선 포함) — B06 reconcile과 같은 패턴으로 사용자 부속값은 보존.
|
||||
section.design = {
|
||||
...(full as NonNullable<typeof section.design>),
|
||||
inlet_structure: section.design.inlet_structure,
|
||||
basin_adjust: section.design.basin_adjust,
|
||||
};
|
||||
}
|
||||
draw();
|
||||
callbacks?.onCrossDesignsUpdated?.();
|
||||
})
|
||||
.catch(() => {
|
||||
/* 프리뷰 실패는 무시 — 화면의 계획선은 그대로 두고 다음 편집에서 다시 시도한다. */
|
||||
});
|
||||
}, CROSS_PREVIEW_DEBOUNCE_MS);
|
||||
}
|
||||
/** 횡단 설계 프리뷰는 `_Profile_Preview` 로 뺐다(700줄 한계). */
|
||||
const crossPreview = createCrossPreview({
|
||||
projectId,
|
||||
detail: () => detail,
|
||||
routeId: () => routeId,
|
||||
edits: () => store.edits(),
|
||||
debounceMs: CROSS_PREVIEW_DEBOUNCE_MS,
|
||||
onApplied: () => {
|
||||
draw();
|
||||
callbacks?.onCrossDesignsUpdated?.();
|
||||
},
|
||||
});
|
||||
const scheduleCrossPreview = (): void => crossPreview.schedule();
|
||||
|
||||
/**
|
||||
* 높이 캐스케이드(2026-08-05 사용자 확정): 자리가 모자라면 ① 종단이 먼저 최소까지
|
||||
@@ -527,6 +498,8 @@ export function createRouteProfilePanel(
|
||||
},
|
||||
selectStation,
|
||||
applyEdits,
|
||||
handleToolPick,
|
||||
toolActive: () => tools.mode() !== "none",
|
||||
stationIdAtStructure,
|
||||
redraw: draw,
|
||||
});
|
||||
@@ -588,7 +561,8 @@ export function createRouteProfilePanel(
|
||||
// 서버 저장분을 기준으로 삼되, 남아 있는 세션 초안이 있으면 그쪽을 우선한다.
|
||||
if (routeChanged || !store.dirty()) {
|
||||
routeId = nextRouteId ?? routeId;
|
||||
store = createProfileEditStore(routeId, stored?.edits ?? emptyEdits(), () => rebuild());
|
||||
savedEdits = stored?.edits ?? emptyEdits();
|
||||
store = createProfileEditStore(routeId, savedEdits, () => rebuild());
|
||||
}
|
||||
base = stored ? toAlignmentBase(stored) : null;
|
||||
// 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지).
|
||||
@@ -712,7 +686,7 @@ export function createRouteProfilePanel(
|
||||
},
|
||||
dispose() {
|
||||
window.clearTimeout(resizeTimer);
|
||||
window.clearTimeout(crossPreviewTimer);
|
||||
crossPreview.dispose();
|
||||
resizeObserver.disconnect();
|
||||
heightResizer.dispose();
|
||||
window.removeEventListener("pointerup", clearDragFlags);
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_Panel_Tools.ts
|
||||
* 종단 패널의 [직선화]·[쉬프트]·되돌리기·방향키 배선 (2026-09-02 사용자 지시).
|
||||
*
|
||||
* 패널 본체(`B05_Profile_UI_Profile_Panel`)가 700줄 한계라 조작 배선만 떼어 놓는다.
|
||||
* 여기서는 상태를 갖지 않고 **본체가 넘긴 접근자**로만 현재 값을 읽는다 — 계획선·편집
|
||||
* 델타의 주인은 그대로 본체다.
|
||||
*
|
||||
* 되돌리기 범위가 B05 조작 전부인 이유는 `B05_Profile_UI_Profile_History` 머리말 참조.
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
AlignmentBase,
|
||||
AlignmentEdits,
|
||||
ProfileAlignment,
|
||||
} from "./B05_Profile_UI_Profile_Alignment";
|
||||
import { adjustStation } from "./B05_Profile_UI_Profile_Alignment";
|
||||
import { createProfileHistory, type ProfileHistory } from "./B05_Profile_UI_Profile_History";
|
||||
import { createProfileTools, type ProfileTools } from "./B05_Profile_UI_Profile_Tools";
|
||||
import {
|
||||
detectStraightRun,
|
||||
shiftStraightRuns,
|
||||
straightenBetween,
|
||||
tiltStraightRun,
|
||||
} from "./B05_Profile_UI_Profile_Straighten";
|
||||
import type { IrregularStation } from "./B05_Profile_UI_IrregularStations";
|
||||
|
||||
/** 방향키 한 번에 움직이는 양(m) — 계획고·누가거리 모두 같다(사용자 확정). */
|
||||
const KEY_STEP_M = 0.1;
|
||||
|
||||
export interface PanelToolsContext {
|
||||
/** 패널 루트 — 키보드 조작이 이 안에서 일어났을 때만 반응한다. */
|
||||
root: HTMLElement;
|
||||
base: () => AlignmentBase | null;
|
||||
alignment: () => ProfileAlignment | null;
|
||||
edits: () => AlignmentEdits;
|
||||
applyEdits: (next: AlignmentEdits) => void;
|
||||
/** 고른 측점 id(없으면 null). */
|
||||
selectedStationId: () => string | null;
|
||||
/** 비정규(구조물) 측점 목록 — 좌우 이동 대상 판정에 쓴다. */
|
||||
irregularStations: () => IrregularStation[];
|
||||
/** 측점 id 만들기 — 목록 id와 그래프 id를 맞춘다. */
|
||||
stationIdOf: (station: IrregularStation) => string;
|
||||
/** 구조물·비정규 측점을 다른 누가거리로 옮긴다(그래프 끌기와 같은 경로). */
|
||||
moveStation: (station: IrregularStation, toChainageM: number) => void;
|
||||
/** 세션 복원 후 화면을 다시 세운다(편집 초안 재적재 포함). */
|
||||
restore: () => void;
|
||||
/** 도구 상태가 바뀌어 요약줄을 다시 그려야 할 때. */
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export interface PanelTools {
|
||||
tools: ProfileTools;
|
||||
history: ProfileHistory;
|
||||
/** 그래프 클릭을 도구가 먹는지 — 먹었으면 기본 선택 동작을 건너뛴다. */
|
||||
handleToolPick: (chainageM: number | null) => boolean;
|
||||
}
|
||||
|
||||
export function createPanelTools(ctx: PanelToolsContext): PanelTools {
|
||||
const history = createProfileHistory(ctx.restore);
|
||||
|
||||
const tools = createProfileTools({
|
||||
onStraighten: (fromM, toM) => {
|
||||
const base = ctx.base();
|
||||
if (!base) return;
|
||||
ctx.applyEdits(straightenBetween(base, ctx.edits(), fromM, toM));
|
||||
tools.clear();
|
||||
},
|
||||
onShift: (runs, delta) => {
|
||||
const base = ctx.base();
|
||||
if (!base) return;
|
||||
ctx.applyEdits(shiftStraightRuns(base, ctx.edits(), runs, delta));
|
||||
},
|
||||
onTilt: (run, delta) => {
|
||||
const base = ctx.base();
|
||||
if (!base) return;
|
||||
ctx.applyEdits(tiltStraightRun(base, ctx.edits(), run, delta));
|
||||
},
|
||||
onUndo: () => history.undo(),
|
||||
onRedo: () => history.redo(),
|
||||
canUndo: () => history.canUndo(),
|
||||
canRedo: () => history.canRedo(),
|
||||
onChanged: ctx.refresh,
|
||||
});
|
||||
|
||||
/** 그래프에서 누른 자리를 도구가 먹는다. */
|
||||
function handleToolPick(chainageM: number | null): boolean {
|
||||
const mode = tools.mode();
|
||||
if (mode === "none") return false;
|
||||
if (chainageM === null) return tools.handleRunPick(null);
|
||||
const alignment = ctx.alignment();
|
||||
if (mode === "shift") {
|
||||
return tools.handleRunPick(alignment ? detectStraightRun(alignment, chainageM) : null);
|
||||
}
|
||||
// 직선화 모드에서 첫 클릭이 **이미 직선화된 라인 안쪽**이면 그 라인을 골라
|
||||
// 틸팅(가운데 라운드 + 양측 탄젠트) 대상으로 삼는다.
|
||||
if (tools.pendingStation() === null && alignment) {
|
||||
const run = detectStraightRun(alignment, chainageM);
|
||||
if (run && Math.abs(run.fromM - chainageM) > 1e-6 && Math.abs(run.toM - chainageM) > 1e-6) {
|
||||
return tools.handleRunPick(run);
|
||||
}
|
||||
}
|
||||
return tools.handleStationPick(chainageM);
|
||||
}
|
||||
|
||||
function selectedIrregular(): IrregularStation | null {
|
||||
const stationId = ctx.selectedStationId();
|
||||
if (stationId === null) return null;
|
||||
return ctx.irregularStations().find((entry) => ctx.stationIdOf(entry) === stationId) ?? null;
|
||||
}
|
||||
|
||||
/** 고른 측점의 누가거리 — 비정규 측점이면 목록에서, 규칙 측점이면 선형에서 찾는다. */
|
||||
function selectedChainage(): number | null {
|
||||
const irregular = selectedIrregular();
|
||||
if (irregular) return irregular.chainage_m;
|
||||
const stationId = ctx.selectedStationId();
|
||||
const row = ctx.alignment()?.stations.find((station) => station.station_id === stationId);
|
||||
return row ? row.chainage_m : null;
|
||||
}
|
||||
|
||||
/* 방향키 — 상·하는 계획고(라운드 포함 기존 틸팅 경로), 좌·우는 누가거리.
|
||||
* 좌우는 구조물·비정규 측점만 움직인다. 20m 정규 측점은 격자라 옮기면 수량·도면
|
||||
* 측점번호가 어긋난다(2026-09-02 사용자 확정). */
|
||||
ctx.root.addEventListener("keydown", (event) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
// 입력칸 안에서는 방향키가 값 조작이므로 손대지 않는다.
|
||||
if (target && target.closest("input, select, textarea")) return;
|
||||
const key = event.key;
|
||||
if ((event.ctrlKey || event.metaKey) && (key === "z" || key === "Z")) {
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) history.redo();
|
||||
else history.undo();
|
||||
return;
|
||||
}
|
||||
if (key !== "ArrowUp" && key !== "ArrowDown" && key !== "ArrowLeft" && key !== "ArrowRight") {
|
||||
return;
|
||||
}
|
||||
const chainage = selectedChainage();
|
||||
const base = ctx.base();
|
||||
if (chainage === null || !base) return;
|
||||
if (key === "ArrowUp" || key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
const delta = key === "ArrowUp" ? KEY_STEP_M : -KEY_STEP_M;
|
||||
ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta));
|
||||
return;
|
||||
}
|
||||
const station = selectedIrregular();
|
||||
if (!station) return; // 규칙 측점은 좌우 이동 대상이 아니다 — 조용히 무시한다.
|
||||
event.preventDefault();
|
||||
const next = Number(
|
||||
(station.chainage_m + (key === "ArrowRight" ? KEY_STEP_M : -KEY_STEP_M)).toFixed(3),
|
||||
);
|
||||
if (next < 0) return;
|
||||
ctx.moveStation(station, next);
|
||||
history.record();
|
||||
});
|
||||
|
||||
return { tools, history, handleToolPick };
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_Preview.ts
|
||||
* 계획선 편집 → 횡단 설계 프리뷰 반영 (패널 본체에서 분리, 2026-09-02 · 700줄 한계).
|
||||
*
|
||||
* 계획고가 바뀌면 측점별 횡단 단면적도 바뀐다 — 서버에 한 번 물어 전 측점을 다시 계산해
|
||||
* **공유 캐시가 들고 있는 같은 객체**를 제자리 갱신한다. 그래야 횡단 기준 유토곡선과
|
||||
* 3D 예상형상이 같은 값으로 따라온다(2026-08-03·08-23 사용자 보고).
|
||||
*
|
||||
* 끌기 중에는 계속 호출되므로 디바운스하고, 늦게 온 응답은 seq 비교로 버린다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { AlignmentEdits } from "./B05_Profile_UI_Profile_Alignment";
|
||||
import { previewCrossDesigns } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
|
||||
export interface CrossPreviewContext {
|
||||
projectId: string;
|
||||
detail: () => SectionDetailResponse | null;
|
||||
routeId: () => number | null;
|
||||
edits: () => AlignmentEdits;
|
||||
debounceMs: number;
|
||||
/** 반영이 끝난 뒤 다시 그린다. */
|
||||
onApplied: () => void;
|
||||
}
|
||||
|
||||
export interface CrossPreview {
|
||||
/** 편집이 있을 때마다 부른다 — 마지막 값만 서버로 나간다. */
|
||||
schedule: () => void;
|
||||
/** 패널을 걷을 때 대기 중인 요청 타이머를 끈다. */
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
export function createCrossPreview(ctx: CrossPreviewContext): CrossPreview {
|
||||
let timer = 0;
|
||||
let seq = 0;
|
||||
|
||||
return {
|
||||
schedule() {
|
||||
if (!ctx.detail() || ctx.routeId() === null) return;
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => {
|
||||
const detail = ctx.detail();
|
||||
const routeId = ctx.routeId();
|
||||
if (!detail || routeId === null) return;
|
||||
const current = (seq += 1);
|
||||
// full_designs — 설계선 좌표까지 통째로 받아야 3D 코리도가 편집 즉시 정확한
|
||||
// 형상으로 재빌드된다(2026-08-23 사용자 지시). 암 경계는 백엔드가 세션값이 없으면
|
||||
// DB 저장 echo를 폴백으로 쓰므로 그대로 유지된다.
|
||||
void previewCrossDesigns(ctx.projectId, routeId, ctx.edits(), undefined, {
|
||||
fullDesigns: true,
|
||||
})
|
||||
.then((next) => {
|
||||
const live = ctx.detail();
|
||||
if (current !== seq || !live || ctx.routeId() !== routeId) return;
|
||||
const designByChainage = new Map(
|
||||
next.designs.map((entry) => [entry.chainage_m.toFixed(3), entry.design]),
|
||||
);
|
||||
for (const section of live.cross_sections) {
|
||||
const full = designByChainage.get(section.chainage_m.toFixed(3));
|
||||
if (!full || !section.design) continue;
|
||||
// 전체 교체(설계선 포함) — B06 reconcile과 같은 패턴으로 사용자 부속값은 보존.
|
||||
section.design = {
|
||||
...(full as NonNullable<typeof section.design>),
|
||||
inlet_structure: section.design.inlet_structure,
|
||||
basin_adjust: section.design.basin_adjust,
|
||||
};
|
||||
}
|
||||
ctx.onApplied();
|
||||
})
|
||||
.catch(() => {
|
||||
/* 프리뷰 실패는 무시 — 화면의 계획선은 그대로 두고 다음 편집에서 다시 시도한다. */
|
||||
});
|
||||
}, ctx.debounceMs);
|
||||
},
|
||||
dispose() {
|
||||
window.clearTimeout(timer);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -21,8 +21,6 @@ import {
|
||||
buildAlignment,
|
||||
controlElevationAt,
|
||||
setCurveRadius,
|
||||
shiftMovingPoints,
|
||||
shiftSegment,
|
||||
type AlignmentBase,
|
||||
type AlignmentEdits,
|
||||
type ProfileAlignment,
|
||||
@@ -85,6 +83,10 @@ export interface ProfileRenderContext {
|
||||
clearMainDragCooldown: () => void;
|
||||
selectStation: (stationId: string | null) => void;
|
||||
applyEdits: (next: AlignmentEdits) => void;
|
||||
/** [직선화]·[쉬프트] 도구가 그래프 클릭을 먼저 먹는지(먹었으면 기본 선택을 건너뛴다). */
|
||||
handleToolPick: (chainageM: number | null) => boolean;
|
||||
/** 그래프 x → chainage 역변환이 필요한 도구 판정용 — 클릭 지점의 누가거리. */
|
||||
toolActive: () => boolean;
|
||||
stationIdAtStructure: (structureId: string | null) => string | null;
|
||||
/** 알약을 골랐을 때처럼 그리는 도중 다시 그려야 하는 자리. */
|
||||
redraw: () => void;
|
||||
@@ -171,6 +173,23 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
// 측점선이 아닌 빈 곳을 누르면 선택 해제(2026-08-04 사용자 지시).
|
||||
// 측점 마커·편집 버튼 클릭은 각자 처리하므로 여기까지 안 온다(closest·stopPropagation).
|
||||
chartWrap.addEventListener("click", (event) => {
|
||||
// [직선화]·[쉬프트] 모드에서는 그래프 클릭이 도구 선택으로 간다 — 측점선을 눌렀으면
|
||||
// 그 측점, 빈 곳이면 그 x의 누가거리로 직선 구간을 고른다(2026-09-02).
|
||||
if (ctx.toolActive()) {
|
||||
const marker = (event.target as HTMLElement).closest(".b06-chart__station");
|
||||
const raw = marker?.getAttribute("data-chainage");
|
||||
if (raw !== null && raw !== undefined) {
|
||||
if (ctx.handleToolPick(Number(raw))) return;
|
||||
} else {
|
||||
const rect = chartWrap.getBoundingClientRect();
|
||||
const chainage = chainageInverter(
|
||||
longitudinal,
|
||||
width,
|
||||
layout.originOffset,
|
||||
)(event.clientX - rect.left + chartWrap.scrollLeft);
|
||||
if (ctx.handleToolPick(Number.isFinite(chainage) ? chainage : null)) return;
|
||||
}
|
||||
}
|
||||
if ((event.target as HTMLElement).closest(".b06-chart__station")) return;
|
||||
if (ctx.selectedStationId() !== null) ctx.selectStation(null);
|
||||
// 구조물 알약 선택도 함께 푼다 — 빈 공간 클릭 시 사이드 폼(구조물군·종류)까지
|
||||
@@ -338,20 +357,6 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
if (blocksMinCover(next)) return;
|
||||
ctx.applyEdits(next);
|
||||
},
|
||||
onSegment: (segment, delta) => {
|
||||
if (!base) return;
|
||||
const next = shiftSegment(base, store.edits(), segment, delta);
|
||||
if (next === store.edits() || blocksMinCover(next)) return;
|
||||
ctx.applyEdits(next);
|
||||
},
|
||||
// 쉬프트 가능 구간 판정 — 안쪽 미틸트 측점 2개(힌지) 확보 못 하면 버튼 숨김.
|
||||
canShift: (segment) => shiftMovingPoints(alignment, segment) !== null,
|
||||
onResetStation: (chainage) => store.resetStation(chainage),
|
||||
// 구간 원복 = 양 끝 측점 오프셋 삭제(측점 원복 연산 ×2).
|
||||
onResetSegment: (segment) => {
|
||||
store.resetStation(segment.from_m);
|
||||
store.resetStation(segment.to_m);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_Straighten.ts
|
||||
* 종단 계획선 [직선화]·[쉬프트] 기하 (2026-09-02 사용자 지시).
|
||||
*
|
||||
* 초기 계획선이 **전체 측점 폴리라인**(계획고 = 지반고)으로 바뀌면서, 구간을 다루는
|
||||
* 조작은 두 가지가 된다.
|
||||
*
|
||||
* [직선화] 측점 2개를 고르면 두 측점의 라운드에 **탄젠트한 직선**으로 잇는다.
|
||||
* 사이에 있던 라운드는 전부 지운다. 두 끝 측점은 변화점으로 남아 있어
|
||||
* 그 자리 라운드가 직선의 접선 역할을 한다.
|
||||
* [쉬프트] 직선화된 라인을 고르면(복수 가능) 그 폴리라인 전체를 위·아래로 옮긴다.
|
||||
* 직선 내부는 한 줄이므로 평행이동 = **최외곽 라운드 중심을 같이 옮기는 것**과
|
||||
* 같고, 바깥 고정점과는 새 직선으로 다시 이어진다.
|
||||
*
|
||||
* 직선 구간은 따로 저장하지 않고 **기하에서 되읽는다**(`detectStraightRun`) — 편집
|
||||
* 델타 스키마(`station_offsets`·`curve_radii`)를 그대로 두어 서버 계약을 건드리지 않는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
AlignmentBase,
|
||||
AlignmentEdits,
|
||||
ProfileAlignment,
|
||||
} from "./B05_Profile_UI_Profile_Alignment";
|
||||
import {
|
||||
buildAlignment,
|
||||
chainageKey,
|
||||
controlElevationAt,
|
||||
} from "./B05_Profile_UI_Profile_Alignment";
|
||||
|
||||
/** 같은 직선 위에 있다고 볼 기울기 차이(무차원). 0.1m 편집 단위의 반올림 오차보다 크게 둔다. */
|
||||
const COLLINEAR_EPSILON = 1e-6;
|
||||
/** 두 chainage를 같은 측점으로 볼 허용 오차(m). */
|
||||
const SAME_STATION_M = 1e-6;
|
||||
|
||||
/** 직선화된 한 구간 — 양 끝 변화점과 그 사이 측점들. */
|
||||
export interface StraightRun {
|
||||
fromM: number;
|
||||
toM: number;
|
||||
/** 양 끝을 포함한 구간 안 변화점 chainage (오름차순). */
|
||||
nodes: number[];
|
||||
}
|
||||
|
||||
/** 현재 선형의 변화점 chainage 목록 (오름차순). */
|
||||
function pviChainages(alignment: ProfileAlignment): number[] {
|
||||
return alignment.pvi.map((node) => node.chainage_m);
|
||||
}
|
||||
|
||||
/** 자동 선형(base_pvi) 위에서의 표고 — 편집 델타의 기준값. */
|
||||
function baseElevationAt(base: AlignmentBase, chainageM: number): number {
|
||||
const nodes = base.basePvi;
|
||||
if (!nodes.length) return 0;
|
||||
if (chainageM <= nodes[0].chainage_m) return nodes[0].elevation_m;
|
||||
const last = nodes[nodes.length - 1];
|
||||
if (chainageM >= last.chainage_m) return last.elevation_m;
|
||||
for (let index = 1; index < nodes.length; index += 1) {
|
||||
if (nodes[index].chainage_m < chainageM) continue;
|
||||
const previous = nodes[index - 1];
|
||||
const current = nodes[index];
|
||||
const span = current.chainage_m - previous.chainage_m;
|
||||
const ratio = span > 1e-12 ? (chainageM - previous.chainage_m) / span : 0;
|
||||
return previous.elevation_m + (current.elevation_m - previous.elevation_m) * ratio;
|
||||
}
|
||||
return last.elevation_m;
|
||||
}
|
||||
|
||||
/** 변화점 사이 기울기 (index 번째 구간). */
|
||||
function gradeAt(alignment: ProfileAlignment, index: number): number {
|
||||
const segment = alignment.segments[index];
|
||||
if (!segment || segment.length_m <= 1e-12) return 0;
|
||||
return segment.height_m / segment.length_m;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그 자리를 지나는 **최대 직선 구간**을 찾는다.
|
||||
*
|
||||
* 인접 구간의 기울기가 같으면(= 변화점에 꺾임이 없으면) 한 직선으로 본다. 전체 측점
|
||||
* 폴리라인에서는 직선화하지 않은 자리마다 기울기가 달라지므로, 직선화한 구간만
|
||||
* 두 측점 이상으로 이어진 직선이 된다.
|
||||
*/
|
||||
export function detectStraightRun(
|
||||
alignment: ProfileAlignment,
|
||||
chainageM: number,
|
||||
): StraightRun | null {
|
||||
const nodes = pviChainages(alignment);
|
||||
if (nodes.length < 2) return null;
|
||||
// 클릭 지점을 포함하는 구간 index
|
||||
let index = alignment.segments.findIndex(
|
||||
(segment) =>
|
||||
chainageM >= segment.from_m - SAME_STATION_M && chainageM <= segment.to_m + SAME_STATION_M,
|
||||
);
|
||||
if (index < 0) return null;
|
||||
const grade = gradeAt(alignment, index);
|
||||
let first = index;
|
||||
let last = index;
|
||||
while (first > 0 && Math.abs(gradeAt(alignment, first - 1) - grade) < COLLINEAR_EPSILON) {
|
||||
first -= 1;
|
||||
}
|
||||
while (
|
||||
last < alignment.segments.length - 1 &&
|
||||
Math.abs(gradeAt(alignment, last + 1) - grade) < COLLINEAR_EPSILON
|
||||
) {
|
||||
last += 1;
|
||||
}
|
||||
// 한 구간(측점 두 개)뿐이면 직선화된 라인이 아니라 그냥 폴리라인 한 마디다.
|
||||
if (last === first) return null;
|
||||
const fromM = alignment.segments[first].from_m;
|
||||
const toM = alignment.segments[last].to_m;
|
||||
return {
|
||||
fromM,
|
||||
toM,
|
||||
nodes: nodes.filter(
|
||||
(value) => value >= fromM - SAME_STATION_M && value <= toM + SAME_STATION_M,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** 두 측점 사이(끝 제외)의 변화점 chainage. */
|
||||
function interiorNodes(alignment: ProfileAlignment, fromM: number, toM: number): number[] {
|
||||
return pviChainages(alignment).filter(
|
||||
(value) => value > fromM + SAME_STATION_M && value < toM - SAME_STATION_M,
|
||||
);
|
||||
}
|
||||
|
||||
/** 편집 델타에 측점 오프셋을 써 넣는다(기존 객체는 건드리지 않는다). */
|
||||
function withOffsets(
|
||||
edits: AlignmentEdits,
|
||||
updates: Array<[string, number]>,
|
||||
dropRadiusKeys: string[] = [],
|
||||
): AlignmentEdits {
|
||||
const stationOffsets = { ...edits.station_offsets };
|
||||
const curveRadii = { ...edits.curve_radii };
|
||||
updates.forEach(([key, value]) => {
|
||||
stationOffsets[key] = Number(value.toFixed(6));
|
||||
});
|
||||
dropRadiusKeys.forEach((key) => delete curveRadii[key]);
|
||||
return { station_offsets: stationOffsets, curve_radii: curveRadii };
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 측점을 직선으로 잇는다 — 사이 측점을 그 직선 위로 옮기고 라운드를 지운다.
|
||||
*
|
||||
* 양 끝 측점의 표고는 건드리지 않는다. 그 자리에 라운드가 있으면 새 직선이 그 라운드의
|
||||
* 접선이 된다(변화점 z는 그대로이고 좌우 직선만 바뀌므로 자동으로 성립한다).
|
||||
*/
|
||||
export function straightenBetween(
|
||||
base: AlignmentBase,
|
||||
edits: AlignmentEdits,
|
||||
fromChainageM: number,
|
||||
toChainageM: number,
|
||||
): AlignmentEdits {
|
||||
const fromM = Math.min(fromChainageM, toChainageM);
|
||||
const toM = Math.max(fromChainageM, toChainageM);
|
||||
const span = toM - fromM;
|
||||
if (span <= SAME_STATION_M) return edits;
|
||||
const current = buildAlignment(base, edits);
|
||||
const startZ = controlElevationAt(current, fromM);
|
||||
const endZ = controlElevationAt(current, toM);
|
||||
const inner = interiorNodes(current, fromM, toM);
|
||||
if (!inner.length) return edits;
|
||||
const updates: Array<[string, number]> = inner.map((chainage) => {
|
||||
const target = startZ + ((endZ - startZ) * (chainage - fromM)) / span;
|
||||
return [chainageKey(chainage), target - baseElevationAt(base, chainage)];
|
||||
});
|
||||
return withOffsets(edits, updates, inner.map(chainageKey));
|
||||
}
|
||||
|
||||
/** 정책에서 라운드 기본 길이 L(m)을 읽는다 — 옛 저장분은 R 기준으로 되돌아간다. */
|
||||
function defaultCurveLength(base: AlignmentBase, deltaGrade: number): number {
|
||||
const length = base.policy.default_curve_length_m;
|
||||
if (typeof length === "number" && Number.isFinite(length) && length > 0) return length;
|
||||
return base.policy.default_curve_radius_m * Math.abs(deltaGrade);
|
||||
}
|
||||
|
||||
/**
|
||||
* 직선화된 구간을 위·아래로 꺾는다 — **가운데 측점에 라운드를 넣고 양쪽에 탄젠트 직선**.
|
||||
*
|
||||
* 가운데 측점을 delta만큼 옮기고, 그 좌우를 각각 새 직선 위에 다시 올린다. 라운드는
|
||||
* 그 변화점에만 생기며(전체 측점 폴리라인은 R을 지정한 자리에만 라운드를 만든다),
|
||||
* R은 기본 곡선길이 L에서 R = L / |대수차| 로 역산해 둔다.
|
||||
*/
|
||||
export function tiltStraightRun(
|
||||
base: AlignmentBase,
|
||||
edits: AlignmentEdits,
|
||||
run: StraightRun,
|
||||
delta: number,
|
||||
): AlignmentEdits {
|
||||
const current = buildAlignment(base, edits);
|
||||
const inner = interiorNodes(current, run.fromM, run.toM);
|
||||
if (!inner.length) return edits;
|
||||
const center = (run.fromM + run.toM) / 2;
|
||||
const pivot = inner.reduce((best, chainage) =>
|
||||
Math.abs(chainage - center) < Math.abs(best - center) ? chainage : best,
|
||||
);
|
||||
const startZ = controlElevationAt(current, run.fromM);
|
||||
const endZ = controlElevationAt(current, run.toM);
|
||||
const span = run.toM - run.fromM;
|
||||
const pivotZ =
|
||||
startZ + ((endZ - startZ) * (pivot - run.fromM)) / (span > 1e-12 ? span : 1) + delta;
|
||||
|
||||
const updates: Array<[string, number]> = [
|
||||
[chainageKey(pivot), pivotZ - baseElevationAt(base, pivot)],
|
||||
];
|
||||
const place = (chainage: number, aM: number, aZ: number, bM: number, bZ: number): void => {
|
||||
const width = bM - aM;
|
||||
if (width <= 1e-12) return;
|
||||
const target = aZ + ((bZ - aZ) * (chainage - aM)) / width;
|
||||
updates.push([chainageKey(chainage), target - baseElevationAt(base, chainage)]);
|
||||
};
|
||||
inner.forEach((chainage) => {
|
||||
if (Math.abs(chainage - pivot) < SAME_STATION_M) return;
|
||||
if (chainage < pivot) place(chainage, run.fromM, startZ, pivot, pivotZ);
|
||||
else place(chainage, pivot, pivotZ, run.toM, endZ);
|
||||
});
|
||||
|
||||
const gradeIn = (pivotZ - startZ) / Math.max(pivot - run.fromM, 1e-12);
|
||||
const gradeOut = (endZ - pivotZ) / Math.max(run.toM - pivot, 1e-12);
|
||||
const deltaGrade = Math.abs(gradeOut - gradeIn);
|
||||
const next = withOffsets(
|
||||
edits,
|
||||
updates,
|
||||
inner.filter((chainage) => Math.abs(chainage - pivot) >= SAME_STATION_M).map(chainageKey),
|
||||
);
|
||||
if (deltaGrade < 1e-9) return next;
|
||||
return {
|
||||
...next,
|
||||
curve_radii: {
|
||||
...next.curve_radii,
|
||||
[chainageKey(pivot)]: Number((defaultCurveLength(base, deltaGrade) / deltaGrade).toFixed(6)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 고른 직선 구간들을 통째로 위·아래로 옮긴다.
|
||||
*
|
||||
* 직선 내부는 한 줄이므로 구간 안 변화점을 모두 같은 델타로 옮기는 것이 곧
|
||||
* **최외곽 라운드 중심을 옮기는 것**이다(사이 점들은 그 사이에 그대로 실려 간다).
|
||||
* 바깥 고정점(BP·EP·다른 변화점)과는 새 기울기의 직선으로 다시 이어진다.
|
||||
*/
|
||||
export function shiftStraightRuns(
|
||||
base: AlignmentBase,
|
||||
edits: AlignmentEdits,
|
||||
runs: StraightRun[],
|
||||
delta: number,
|
||||
): AlignmentEdits {
|
||||
if (!runs.length) return edits;
|
||||
const current = buildAlignment(base, edits);
|
||||
const moving = new Set<number>();
|
||||
runs.forEach((run) => run.nodes.forEach((chainage) => moving.add(chainage)));
|
||||
const updates: Array<[string, number]> = [...moving].map((chainage) => {
|
||||
const target = controlElevationAt(current, chainage) + delta;
|
||||
return [chainageKey(chainage), target - baseElevationAt(base, chainage)];
|
||||
});
|
||||
return withOffsets(edits, updates);
|
||||
}
|
||||
|
||||
/** 같은 구간인지 — 선택 목록에서 중복을 걸러낼 때 쓴다. */
|
||||
export function sameRun(left: StraightRun, right: StraightRun): boolean {
|
||||
return (
|
||||
Math.abs(left.fromM - right.fromM) < SAME_STATION_M &&
|
||||
Math.abs(left.toM - right.toM) < SAME_STATION_M
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,12 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
|
||||
import { isPipeStation, type IrregularStation } from "./B05_Profile_UI_IrregularStations";
|
||||
import {
|
||||
irregularStationId,
|
||||
isPipeStation,
|
||||
type IrregularStation,
|
||||
} from "./B05_Profile_UI_IrregularStations";
|
||||
import { structureAnchorM, type StructureInstance } from "./B05_Profile_Api_Structures";
|
||||
import { GROUP_LABELS } from "./B05_Profile_UI_Structures_Panel";
|
||||
|
||||
/** 우클릭 메뉴용 최소 타입 정보 — 사이드 「구조물 배치」 종류 목록과 같은 원천. */
|
||||
@@ -117,3 +122,42 @@ export function mountStructureMenu(host: HTMLElement, options: StructureLineOpti
|
||||
|
||||
host.append(menu.element);
|
||||
}
|
||||
|
||||
/** 같은 자리로 볼 여유(m) — 측점선과 알약은 같은 누가거리를 쓰지만 소수점이 갈린다. */
|
||||
export const SAME_CHAINAGE_M = 0.51;
|
||||
|
||||
/**
|
||||
* 측점선 id ↔ 알약(구조물) id 짝짓기.
|
||||
*
|
||||
* 둘은 한 구조물의 두 표시라 선택이 함께 움직여야 한다(2026-08-17 사용자 지시).
|
||||
* 패널 본체가 700줄 한계라 이 짝짓기만 여기로 옮겼다(2026-09-02).
|
||||
*/
|
||||
export function structureIdAtStation(
|
||||
stationId: string | null,
|
||||
stations: IrregularStation[],
|
||||
structures: StructureInstance[],
|
||||
): string | null {
|
||||
if (stationId === null) return null;
|
||||
const prefix = irregularStationId("");
|
||||
if (!stationId.startsWith(prefix)) return null;
|
||||
const station = stations.find((entry) => irregularStationId(entry.id) === stationId);
|
||||
if (!station) return null;
|
||||
const hit = structures.find(
|
||||
(item) => Math.abs(structureAnchorM(item) - station.chainage_m) < SAME_CHAINAGE_M,
|
||||
);
|
||||
return hit?.structure_id ?? null;
|
||||
}
|
||||
|
||||
/** 알약(구조물) id → 측점선 id. 세로선이 없는 구조물(A군 외)이면 null. */
|
||||
export function stationIdAtStructure(
|
||||
structureId: string | null,
|
||||
stations: IrregularStation[],
|
||||
structures: StructureInstance[],
|
||||
): string | null {
|
||||
if (structureId === null) return null;
|
||||
const structure = structures.find((item) => item.structure_id === structureId);
|
||||
if (!structure) return null;
|
||||
const anchor = structureAnchorM(structure);
|
||||
const station = stations.find((entry) => Math.abs(entry.chainage_m - anchor) < SAME_CHAINAGE_M);
|
||||
return station ? irregularStationId(station.id) : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_Tools.ts
|
||||
* 종단 계획선 도구줄 — [↶][↷] · [직선화] · [쉬프트] · [▲][▼] (2026-09-02 사용자 지시).
|
||||
*
|
||||
* 자리는 절·성토 요약줄의 **맨 앞**(최대 기울기 칩 왼쪽)이고, 버튼 크기는 그래프 위
|
||||
* 틸팅 버튼과 같다(`b05-profile-edit__btn` 계열 크기를 CSS에서 공유).
|
||||
*
|
||||
* 선택 흐름:
|
||||
* [직선화] → 측점 2개를 그래프에서 고름 → 두 측점의 라운드에 탄젠트한 직선으로 대체.
|
||||
* [쉬프트] → 직선화된 라인을 고름(여러 개 가능) → [▲][▼]로 통째로 올리고 내림.
|
||||
* 직선을 고른 채 [직선화] 모드에서 [▲][▼]를 누르면 그 직선이 꺾이며 가운데에
|
||||
* 라운드가 생기고 양쪽이 탄젠트 직선이 된다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { StraightRun } from "./B05_Profile_UI_Profile_Straighten";
|
||||
import { sameRun } from "./B05_Profile_UI_Profile_Straighten";
|
||||
|
||||
export type ProfileToolMode = "none" | "straighten" | "shift";
|
||||
|
||||
export interface ProfileToolsCallbacks {
|
||||
/** 두 측점을 직선으로 잇는다. */
|
||||
onStraighten: (fromChainageM: number, toChainageM: number) => void;
|
||||
/** 고른 직선 구간(들)을 위·아래로 옮긴다. */
|
||||
onShift: (runs: StraightRun[], delta: number) => void;
|
||||
/** 고른 직선 구간을 꺾는다 — 가운데 라운드 + 양측 탄젠트. */
|
||||
onTilt: (run: StraightRun, delta: number) => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
canUndo: () => boolean;
|
||||
canRedo: () => boolean;
|
||||
/** 선택 표시를 갱신해야 할 때(모드·선택 변화) 호출된다. */
|
||||
onChanged: () => void;
|
||||
}
|
||||
|
||||
export interface ProfileTools {
|
||||
/** 요약줄 맨 앞에 넣을 도구 묶음. 그릴 때마다 새로 만든다. */
|
||||
render: () => HTMLElement;
|
||||
mode: () => ProfileToolMode;
|
||||
/** 그래프에서 측점을 눌렀을 때 — 도구가 삼켰으면 true. */
|
||||
handleStationPick: (chainageM: number) => boolean;
|
||||
/** 그래프에서 직선을 눌렀을 때(구간 판정 결과) — 도구가 삼켰으면 true. */
|
||||
handleRunPick: (run: StraightRun | null) => boolean;
|
||||
/** 선택 중인 직선 구간들(강조 표시용). */
|
||||
selectedRuns: () => StraightRun[];
|
||||
/** 직선화 대기 중 첫 측점(강조 표시용). */
|
||||
pendingStation: () => number | null;
|
||||
/** 모드·선택을 모두 끈다. */
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
function toolButton(label: string, title: string, onClick: () => void): HTMLButtonElement {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "b05-route-profile__tool";
|
||||
button.textContent = label;
|
||||
button.title = title;
|
||||
button.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileTools {
|
||||
let mode: ProfileToolMode = "none";
|
||||
let pending: number | null = null;
|
||||
let runs: StraightRun[] = [];
|
||||
|
||||
function reset(): void {
|
||||
mode = "none";
|
||||
pending = null;
|
||||
runs = [];
|
||||
}
|
||||
|
||||
function setMode(next: ProfileToolMode): void {
|
||||
// 같은 버튼을 다시 누르면 모드를 끈다 — 선택도 함께 비운다.
|
||||
if (mode === next) reset();
|
||||
else {
|
||||
mode = next;
|
||||
pending = null;
|
||||
runs = [];
|
||||
}
|
||||
callbacks.onChanged();
|
||||
}
|
||||
|
||||
function step(delta: number): void {
|
||||
if (mode === "shift" && runs.length) {
|
||||
callbacks.onShift(runs, delta);
|
||||
return;
|
||||
}
|
||||
if (runs.length === 1) callbacks.onTilt(runs[0], delta);
|
||||
}
|
||||
|
||||
return {
|
||||
mode: () => mode,
|
||||
selectedRuns: () => runs,
|
||||
pendingStation: () => pending,
|
||||
clear() {
|
||||
if (mode === "none" && pending === null && !runs.length) return;
|
||||
reset();
|
||||
callbacks.onChanged();
|
||||
},
|
||||
handleStationPick(chainageM) {
|
||||
if (mode !== "straighten") return false;
|
||||
if (pending === null) {
|
||||
pending = chainageM;
|
||||
callbacks.onChanged();
|
||||
return true;
|
||||
}
|
||||
const from = pending;
|
||||
pending = null;
|
||||
if (Math.abs(from - chainageM) < 1e-6) {
|
||||
callbacks.onChanged();
|
||||
return true;
|
||||
}
|
||||
callbacks.onStraighten(from, chainageM);
|
||||
return true;
|
||||
},
|
||||
handleRunPick(run) {
|
||||
if (mode === "none") return false;
|
||||
if (!run) {
|
||||
// 빈 곳을 누르면 선택만 비우고 모드는 유지한다 — 연속 조작을 끊지 않는다.
|
||||
if (runs.length || pending !== null) {
|
||||
runs = [];
|
||||
pending = null;
|
||||
callbacks.onChanged();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const already = runs.findIndex((entry) => sameRun(entry, run));
|
||||
if (already >= 0) runs.splice(already, 1);
|
||||
else if (mode === "shift") runs.push(run);
|
||||
else runs = [run];
|
||||
callbacks.onChanged();
|
||||
return true;
|
||||
},
|
||||
render() {
|
||||
const wrap = document.createElement("span");
|
||||
wrap.className = "b05-route-profile__tools";
|
||||
|
||||
const undo = toolButton("↶", "되돌리기 (Ctrl+Z)", callbacks.onUndo);
|
||||
undo.disabled = !callbacks.canUndo();
|
||||
const redo = toolButton("↷", "다시하기 (Ctrl+Shift+Z)", callbacks.onRedo);
|
||||
redo.disabled = !callbacks.canRedo();
|
||||
|
||||
const straighten = toolButton(
|
||||
"직선화",
|
||||
"측점 2개를 골라 그 사이를 직선으로 만듭니다 (사이 라운드는 지워집니다)",
|
||||
() => setMode("straighten"),
|
||||
);
|
||||
straighten.classList.toggle("is-active", mode === "straighten");
|
||||
const shift = toolButton(
|
||||
"쉬프트",
|
||||
"직선화된 라인을 골라(여러 개 가능) 위·아래로 옮깁니다",
|
||||
() => setMode("shift"),
|
||||
);
|
||||
shift.classList.toggle("is-active", mode === "shift");
|
||||
|
||||
const up = toolButton("▲", "고른 직선을 0.1m 올림", () => step(0.1));
|
||||
const down = toolButton("▼", "고른 직선을 0.1m 내림", () => step(-0.1));
|
||||
const idle = mode === "none" || (!runs.length && mode === "shift");
|
||||
up.disabled = idle || (mode === "straighten" && runs.length !== 1);
|
||||
down.disabled = up.disabled;
|
||||
|
||||
wrap.append(undo, redo, straighten, shift, up, down);
|
||||
if (mode === "straighten" && pending !== null) {
|
||||
const hint = document.createElement("em");
|
||||
hint.className = "b05-route-profile__tool-hint";
|
||||
hint.textContent = `${pending.toFixed(1)}m 선택 — 두 번째 측점을 고르세요`;
|
||||
wrap.append(hint);
|
||||
} else if (mode === "shift" && !runs.length) {
|
||||
const hint = document.createElement("em");
|
||||
hint.className = "b05-route-profile__tool-hint";
|
||||
hint.textContent = "직선화된 라인을 고르세요";
|
||||
wrap.append(hint);
|
||||
}
|
||||
return wrap;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -544,17 +544,45 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.b05-route-profile__balance-reset {
|
||||
/* 도구줄 — 요약줄 맨 앞(최대 기울기 칩 왼쪽). 버튼 크기는 그래프 위 틸팅 버튼과 같다
|
||||
(21×17px, 2026-09-02 사용자 지시). */
|
||||
.b05-route-profile__tools {
|
||||
display: inline-flex;
|
||||
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);
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-right: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b05-route-profile__tool {
|
||||
height: 17px;
|
||||
min-width: 21px;
|
||||
padding: 0 3px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 75%, transparent);
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--color-surface-raised) 80%, transparent);
|
||||
color: var(--color-text);
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b05-route-profile__tool:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.b05-route-profile__tool.is-active {
|
||||
border-color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 22%, transparent);
|
||||
}
|
||||
|
||||
.b05-route-profile__tool-hint {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* ─── 도면 테이블 (구배 ~ 곡선 9행) ───────────────────────────────────────
|
||||
셀은 종단면도와 같은 X 매핑으로 절대 배치되어 측점 수직선과 맞물린다.
|
||||
행 이름표만 sticky로 좌측에 고정되어 가로 스크롤에도 계속 보인다. */
|
||||
|
||||
@@ -284,6 +284,8 @@ export function createLongitudinalProfile(
|
||||
const selected = station.station_id === selectedStationId;
|
||||
const marker = svgElement("g", {
|
||||
class: `b06-chart__station${selected ? " b06-chart__station--selected" : ""}`,
|
||||
// B05 [직선화]·[쉬프트] 도구가 클릭한 측점의 누가거리를 여기서 읽는다(2026-09-02).
|
||||
"data-chainage": station.chainage_m.toFixed(3),
|
||||
tabindex: "0",
|
||||
role: "button",
|
||||
"aria-label": `${stationLabel(station.chainage_m, stationInterval)} ${station.chainage_m.toFixed(1)}m`,
|
||||
|
||||
Reference in New Issue
Block a user