From 3651ad987576c13ef9c72159a83ba261f5e8e471 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 2 Sep 2026 19:30:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20=EC=A2=85=EB=8B=A8=20=EA=B3=84?= =?UTF-8?q?=ED=9A=8D=EC=84=A0=20=EC=A0=84=EC=B2=B4=20=EC=B8=A1=EC=A0=90=20?= =?UTF-8?q?=ED=8F=B4=EB=A6=AC=EB=9D=BC=EC=9D=B8=20=EC=A0=84=ED=99=98=20+?= =?UTF-8?q?=20=EC=A7=81=EC=84=A0=ED=99=94=C2=B7=EC=89=AC=ED=94=84=ED=8A=B8?= =?UTF-8?q?=C2=B7undo/redo=20=EC=8B=A0=EC=84=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- B03_FileInput/B03_FileInput_Service_Chain.py | 2 +- B05_Profile/B05_Profile_Engine_Grade.py | 4 +- .../B05_Profile_Engine_Grade_Alignment.py | 22 +- .../B05_Profile_Engine_Grade_Profile.py | 94 ++++++- B05_Profile/B05_Profile_Engine_Sections.py | 19 +- B05_Profile/B05_Profile_Schema.py | 3 +- B05_Profile/B05_Profile_UI_Panel.ts | 3 + .../B05_Profile_UI_Profile_Alignment.ts | 18 +- B05_Profile/B05_Profile_UI_Profile_Balance.ts | 41 +-- B05_Profile/B05_Profile_UI_Profile_Edit.ts | 106 +------ B05_Profile/B05_Profile_UI_Profile_History.ts | 124 +++++++++ B05_Profile/B05_Profile_UI_Profile_Panel.ts | 134 ++++----- .../B05_Profile_UI_Profile_Panel_Tools.ts | 159 +++++++++++ B05_Profile/B05_Profile_UI_Profile_Preview.ts | 79 ++++++ B05_Profile/B05_Profile_UI_Profile_Render.ts | 37 +-- .../B05_Profile_UI_Profile_Straighten.ts | 263 ++++++++++++++++++ .../B05_Profile_UI_Profile_Structures.ts | 46 ++- B05_Profile/B05_Profile_UI_Profile_Tools.ts | 180 ++++++++++++ B05_Profile/B05_Profile_UI_Style.css | 42 ++- B06_Section/B06_Section_UI_Longitudinal.ts | 2 + 20 files changed, 1133 insertions(+), 245 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Profile_History.ts create mode 100644 B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts create mode 100644 B05_Profile/B05_Profile_UI_Profile_Preview.ts create mode 100644 B05_Profile/B05_Profile_UI_Profile_Straighten.ts create mode 100644 B05_Profile/B05_Profile_UI_Profile_Tools.ts diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index 39bd3125..e1fb177d 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -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"), diff --git a/B05_Profile/B05_Profile_Engine_Grade.py b/B05_Profile/B05_Profile_Engine_Grade.py index cd4e1b6f..c7574a34 100644 --- a/B05_Profile/B05_Profile_Engine_Grade.py +++ b/B05_Profile/B05_Profile_Engine_Grade.py @@ -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 diff --git a/B05_Profile/B05_Profile_Engine_Grade_Alignment.py b/B05_Profile/B05_Profile_Engine_Grade_Alignment.py index b76f9be8..005d5a8a 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Alignment.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Alignment.py @@ -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) diff --git a/B05_Profile/B05_Profile_Engine_Grade_Profile.py b/B05_Profile/B05_Profile_Engine_Grade_Profile.py index 87cdae81..c38a6a8f 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Profile.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Profile.py @@ -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), diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index 9596f81b..9bf92f92 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -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, diff --git a/B05_Profile/B05_Profile_Schema.py b/B05_Profile/B05_Profile_Schema.py index 2c89dc1a..0ccfff61 100644 --- a/B05_Profile/B05_Profile_Schema.py +++ b/B05_Profile/B05_Profile_Schema.py @@ -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)를 쓴다. diff --git a/B05_Profile/B05_Profile_UI_Panel.ts b/B05_Profile/B05_Profile_UI_Panel.ts index 7e58e2c9..21ecbd2e 100644 --- a/B05_Profile/B05_Profile_UI_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Panel.ts @@ -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 사용자 지시) — 기본 해제. 켜면 자동 계획선이 배관 diff --git a/B05_Profile/B05_Profile_UI_Profile_Alignment.ts b/B05_Profile/B05_Profile_UI_Profile_Alignment.ts index 2c84fa49..929494dc 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Alignment.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Alignment.ts @@ -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, 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, diff --git a/B05_Profile/B05_Profile_UI_Profile_Balance.ts b/B05_Profile/B05_Profile_UI_Profile_Balance.ts index 92ab128b..5f7f7c68 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Balance.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Balance.ts @@ -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"; diff --git a/B05_Profile/B05_Profile_UI_Profile_Edit.ts b/B05_Profile/B05_Profile_UI_Profile_Edit.ts index d762e5b5..e6892e8e 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Edit.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Edit.ts @@ -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; } diff --git a/B05_Profile/B05_Profile_UI_Profile_History.ts b/B05_Profile/B05_Profile_UI_Profile_History.ts new file mode 100644 index 00000000..701ae660 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_History.ts @@ -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; + +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, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index a9c1e43d..01e5ea34 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -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), - 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); diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts new file mode 100644 index 00000000..b765c5ba --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -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 }; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Preview.ts b/B05_Profile/B05_Profile_UI_Profile_Preview.ts new file mode 100644 index 00000000..5434af36 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_Preview.ts @@ -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), + inlet_structure: section.design.inlet_structure, + basin_adjust: section.design.basin_adjust, + }; + } + ctx.onApplied(); + }) + .catch(() => { + /* 프리뷰 실패는 무시 — 화면의 계획선은 그대로 두고 다음 편집에서 다시 시도한다. */ + }); + }, ctx.debounceMs); + }, + dispose() { + window.clearTimeout(timer); + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 6f4a2d8d..2921a9a1 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -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); - }, }), ); } diff --git a/B05_Profile/B05_Profile_UI_Profile_Straighten.ts b/B05_Profile/B05_Profile_UI_Profile_Straighten.ts new file mode 100644 index 00000000..1bcc587e --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_Straighten.ts @@ -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(); + 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 + ); +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Structures.ts b/B05_Profile/B05_Profile_UI_Profile_Structures.ts index b23b6a21..c22646ad 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Structures.ts @@ -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; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Tools.ts new file mode 100644 index 00000000..e42c66a6 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_Tools.ts @@ -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; + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Style.css b/B05_Profile/B05_Profile_UI_Style.css index c27e729a..94062e8f 100644 --- a/B05_Profile/B05_Profile_UI_Style.css +++ b/B05_Profile/B05_Profile_UI_Style.css @@ -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로 좌측에 고정되어 가로 스크롤에도 계속 보인다. */ diff --git a/B06_Section/B06_Section_UI_Longitudinal.ts b/B06_Section/B06_Section_UI_Longitudinal.ts index 4599b034..5cccae88 100644 --- a/B06_Section/B06_Section_UI_Longitudinal.ts +++ b/B06_Section/B06_Section_UI_Longitudinal.ts @@ -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`,