diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Grade_Profile.py b/B05_wf2_Route/B05_wf2_Route_Engine_Grade_Profile.py index 38b637e1..02e5c0c8 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Grade_Profile.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Grade_Profile.py @@ -5,8 +5,13 @@ [[B05_wf2_Route_Engine_Grade_Alignment]] 의 기하 파생을 묶어 `design_profiles` 배열에 넣을 계획선 한 벌을 만든다. -두 진입점이 있다. - - `design_alignment_profile()` : 노선 계산 직후. 직선 분할 DP부터 새로 푼다. +세 진입점이 있다. + - `design_pipe_anchored_profile()` : **1차(기본)**. 배수유역도가 산출한 배관 배치 측점을 + 변화점으로 삼아, 계획선이 각 배관 자리에서 지면선과 만나도록(계획고 = 지반고) 시작점 → + 배관1 → 배관2 → … → 종점을 직선으로 잇고 기본 R을 얹는다(2026-08-03 사용자 확정). + 배관(암거)은 계곡 유하부라 계획선이 그 지점에 붙어야 복토·유입 조건이 성립한다. + - `design_alignment_profile()` : **2차(폴백)**. 배관이 없거나 1차 산출이 불가할 때 + 쓰는 기존 지반 추종 직선 분할 DP 선형. - `rebuild_alignment_profile()`: 사용자 편집 확정 시. **저장된 자동 선형(base_pvi)과 정책을 그대로 재사용**하고 편집 델타만 다시 얹는다. DP를 다시 돌리면 기준선이 흔들려 "원복" 이 원래 위치로 돌아가지 않기 때문이다. @@ -110,6 +115,82 @@ def _profile_entry( } +def design_pipe_anchored_profile( + longitudinal: dict[str, Any], + options: GradeDesignOptions, + pipe_chainages: list[float], + *, + station_interval_m: float | None = None, + edits: dict[str, Any] | None = None, +) -> tuple[dict[str, Any], dict[str, Any]]: + """배관 배치 측점을 변화점으로 삼는 1차 계획선. + + 변화점 표고는 그 자리의 지반고다 — 계획선이 지면선과 교차하는 지점에 배관이 앉는다. + 시·종점은 지반고에 사용자 오프셋을 더한다(기존 계약 유지). 종단곡선 R은 + `build_alignment`이 기본값(`default_curve_radius_m`)으로 얹고, 기울기 위반은 + 막지 않고 경고로 남긴다 — 배관 위치가 우선이고 조정은 사용자 몫이다. + """ + options.validate() + chainage, ground = ground_profile(longitudinal) + total = float(chainage[-1]) + if total <= 0: + raise ValueError("종단 연장이 0이어서 계획선을 만들 수 없습니다.") + + stations = list(longitudinal.get("stations") or []) + interval = float(station_interval_m or 0) or infer_station_interval(stations) + policy = AlignmentPolicy.from_config( + station_interval_m=interval, + max_grade_pct=options.max_grade_pct, + curve_skip_delta_pct=options.vertical_curve_skip_delta_pct, + paved=options.paved, + ) + + warnings = list(options.warnings) + fixed = ( + float(ground[0]) + options.start_elevation_offset_m, + float(ground[-1]) + options.end_elevation_offset_m, + ) + rise = fixed[1] - fixed[0] + direction, note = ( + detect_main_direction(ground, rise) + if options.main_direction == "auto" + else (options.main_direction, None) + ) + if note: + warnings.append(note) + + # 범위 밖·양끝에 붙은 것은 버리고, 서로 붙은 배관(0.5m 미만)은 하나로 본다. + margin = 0.5 + anchors: list[float] = [] + for value in sorted(float(c) for c in pipe_chainages): + if value <= margin or value >= total - margin: + continue + if anchors and value - anchors[-1] < margin: + continue + anchors.append(round(value, 3)) + if not anchors: + raise ValueError("계획선 변화점으로 쓸 배관 배치 측점이 없습니다.") + + base_s = np.array([0.0, *anchors, total], dtype=np.float64) + # 배관 자리 표고 = 지반고(지면선 교차). 시·종점만 오프셋을 얹는다. + base_z = np.interp(base_s, chainage, ground) + base_z[0] = fixed[0] + base_z[-1] = fixed[1] + + alignment = build_alignment( + base_s=base_s, + base_z=base_z, + chainage=chainage, + ground=ground, + stations=stations, + policy=policy, + edits=edits, + ) + warnings.extend(alignment["warnings"]) + balanced = bool(alignment["balance"]["within_tolerance"]) + return alignment, _profile_entry(alignment, options, direction, balanced, warnings) + + def design_alignment_profile( longitudinal: dict[str, Any], options: GradeDesignOptions, diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py b/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py index 67aec60d..95f72f65 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py @@ -12,14 +12,24 @@ from pathlib import Path from typing import Any from B05_wf2_Route.B05_wf2_Route_Engine_Grade import GradeDesignOptions, design_grade_line -from B05_wf2_Route.B05_wf2_Route_Engine_Grade_Profile import design_alignment_profile +from B05_wf2_Route.B05_wf2_Route_Engine_Grade_Profile import ( + design_alignment_profile, + design_pipe_anchored_profile, +) from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import ( SectionGenerationOptions, generate_sections, ) +from common_util.common_util_drainage_pipes import parse_pipe_points, route_signature from common_util.common_util_json import atomic_write_json +from common_util.common_util_route_geometry import RouteVertex from common_util.common_util_surface_sampler import build_surface_sampler -from config.config_system import FOREST_ROAD_PROFILE_CRITERIA +from config.config_system import ( + DRAINAGE_CACHE_DIRNAME, + DRAINAGE_EDITS_DIRNAME, + DRAINAGE_PIPE_POINTS_FILENAME, + FOREST_ROAD_PROFILE_CRITERIA, +) logger = logging.getLogger(__name__) @@ -79,33 +89,81 @@ def _cross_summary(cross_section: dict[str, Any]) -> dict[str, Any]: } +def _load_pipe_chainages(project_root: Path, polyline: list[list[float]]) -> list[float]: + """배수유역도가 확정한 배관 배치 측점(누가거리)을 읽는다. + + 관 지점 파일에는 저장 당시 노선 지문이 함께 있다 — 노선이 바뀌었으면 버린다 + (옛 노선의 배관 자리로 계획선을 앉히면 전부 어긋난다). 파일이 없거나 못 읽으면 빈 목록. + """ + path = ( + project_root + / "B04_wf1_Surface" + / DRAINAGE_CACHE_DIRNAME + / DRAINAGE_EDITS_DIRNAME + / DRAINAGE_PIPE_POINTS_FILENAME + ) + if not path.is_file(): + return [] + try: + document = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + logger.warning("B05 계획선: 관 지점 파일을 읽지 못했습니다 (%s)", path) + return [] + vertices = [ + RouteVertex( + x=float(p[0]), y=float(p[1]), z=float(p[2]) if len(p) > 2 else 0.0, chainage_m=0.0 + ) + for p in polyline + ] + if str(document.get("route_signature") or "") != route_signature(vertices): + logger.info("B05 계획선: 노선이 바뀌어 저장된 관 지점을 쓰지 않습니다.") + return [] + return [pipe.chainage_m for pipe in parse_pipe_points(document.get("points"))] + + def _append_design_profiles( longitudinal: dict[str, Any], grade_options: GradeDesignOptions | None, station_interval_m: float | None = None, + pipe_chainages: list[float] | None = None, ) -> dict[str, Any] | None: """종단 계획선을 산출해 longitudinal에 붙이고 요약을 반환한다. - 1순위는 측점 제약 직선 분할 선형(`profile_alignment`)이며, 여기서 산출된 - 변화점 구조가 사용자 편집의 기준선이 된다. 산출이 실패하면 구 균형 최적화 - 계획선으로 폴백하고(편집 불가), 그마저 실패해도 종횡단 생성 자체는 유지한다. - 횡단 설계 기반 계획선을 나중에 추가할 수 있게 배열로 보관한다. + 1순위는 **배관 정착 선형** — 배수유역도의 배관 배치 측점마다 계획선이 지면선과 + 만나도록 직선으로 잇고 기본 R을 얹는다(2026-08-03 사용자 확정). 배관이 없거나 + 산출이 불가하면 2순위로 기존 지반 추종 직선 분할 선형(`design_alignment_profile`), + 그마저 실패하면 구 균형 최적화 계획선으로 폴백하고(편집 불가), 모두 실패해도 + 종횡단 생성 자체는 유지한다. """ longitudinal.setdefault("design_profiles", []) if grade_options is None: return None - try: - alignment, profile = design_alignment_profile( - longitudinal, grade_options, station_interval_m=station_interval_m - ) - longitudinal["profile_alignment"] = alignment - except (ValueError, KeyError, ArithmeticError): - logger.exception("B05 계획선 선형 산출 실패 — 균형 최적화 계획선으로 대체") + alignment = None + profile = None + if pipe_chainages: try: - profile = design_grade_line(longitudinal, grade_options) + alignment, profile = design_pipe_anchored_profile( + longitudinal, + grade_options, + pipe_chainages, + station_interval_m=station_interval_m, + ) except (ValueError, KeyError, ArithmeticError): - logger.exception("B05 종단 계획선 산출 실패 (종횡단은 유지)") - return None + logger.exception("B05 배관 정착 계획선 산출 실패 — 직선 분할 선형으로 대체") + if profile is None: + try: + alignment, profile = design_alignment_profile( + longitudinal, grade_options, station_interval_m=station_interval_m + ) + except (ValueError, KeyError, ArithmeticError): + logger.exception("B05 계획선 선형 산출 실패 — 균형 최적화 계획선으로 대체") + try: + profile = design_grade_line(longitudinal, grade_options) + except (ValueError, KeyError, ArithmeticError): + logger.exception("B05 종단 계획선 산출 실패 (종횡단은 유지)") + return None + if alignment is not None: + longitudinal["profile_alignment"] = alignment longitudinal["design_profiles"].append(profile) return {"id": profile["id"], **profile["summary"]} @@ -207,6 +265,8 @@ def run_section_generation( result["longitudinal"], grade_options, (result.get("options") or {}).get("station_interval_m"), + # 1차 계획선의 변화점 = 배수유역도가 확정한 배관 배치 측점. + pipe_chainages=_load_pipe_chainages(project_root, polyline), ) # 계획선 경사 기반 포장 제안(법정 상한 초과 측점) — 비치명적. try: diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul.ts index c3b130e4..1bcb3107 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul.ts @@ -31,9 +31,11 @@ import type { MassHaulSeries } from "@util/common_util_mass_haul"; import type { HaulPlan } from "@util/common_util_mass_haul_balance"; import type { MassHaulAxis } from "@util/common_util_mass_haul_view"; import { + applyLegendToggle, computeMassHaulSeries, MASS_HAUL_BALANCE_KEY, MASS_HAUL_DEFAULT_VISIBLE, + normalizeVisibleBasis, } from "@util/common_util_mass_haul"; import { computeHaulPlan } from "@util/common_util_mass_haul_balance"; import { resetBalloonOffsets } from "@util/common_util_mass_haul_balance_view"; @@ -66,6 +68,8 @@ export interface RouteMassHaulContext { } export interface RouteMassHaulDrawParams { + /** 범례 오버레이의 세로 위치(px) — 종단 그래프 높이 + 여백. B06과 같은 문법. */ + legendTopPx: number; /** 측점선을 세울 목록 — 종단 그래프에 넣은 것과 **같은 배열**이어야 자리가 맞는다. */ stationSource: MassHaulStationSource; /** 종단 개략 곡선 입력 — 편집이 반영된 현재 계획선을 담은 종단 데이터. */ @@ -84,8 +88,13 @@ export interface RouteMassHaulDrawParams { export interface RouteMassHaulPanel { /** 종단면 패널 바닥에 붙는 2차 슬라이드 손잡이. */ handle: HTMLElement; - /** 범례·요약 막대 — 가로 스크롤러 **밖**에 놓는다. */ + /** 요약 막대 — 가로 스크롤러 **밖**에 놓는다. */ bar: HTMLElement; + /** + * 범례 오버레이 — 유토곡선 우상단에 절대배치(B06과 동일 문법, 2026-08-03 사용자 지시). + * 호출한 쪽이 `position: relative`인 패널 본문에 붙인다. + */ + legendLayer: HTMLElement; isOpen(): boolean; setContext(next: RouteMassHaulContext | null): void; /** @@ -100,7 +109,10 @@ function readVisible(): Set { const raw = sessionStorage.getItem(VISIBLE_KEY); if (!raw) return new Set(DEFAULT_VISIBLE); const parsed = JSON.parse(raw) as unknown; - return Array.isArray(parsed) ? new Set(parsed.map(String)) : new Set(DEFAULT_VISIBLE); + // 기준 키는 라디오라 항상 하나로 눌러 맞춘다(B06 읽기와 같은 규칙). + return Array.isArray(parsed) + ? normalizeVisibleBasis(new Set(parsed.map(String))) + : new Set(DEFAULT_VISIBLE); } catch { return new Set(DEFAULT_VISIBLE); } @@ -120,6 +132,9 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa const bar = document.createElement("div"); bar.className = "b05-profile__masshaul-bar"; + const legendLayer = document.createElement("div"); + legendLayer.className = "b05-profile__masshaul-legend"; + legendLayer.hidden = true; let open = sessionStorage.getItem(OPEN_KEY) === "true"; let context: RouteMassHaulContext | null = null; @@ -130,18 +145,19 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa handleControl.setOpen(next); handle.classList.toggle("is-open", next); bar.hidden = !next; + legendLayer.hidden = !next; onChanged(); } handleControl.setOpen(open); handle.classList.toggle("is-open", open); bar.hidden = !open; + legendLayer.hidden = !open; handleControl.root.addEventListener("click", () => applyOpen(!open)); caption.addEventListener("click", () => applyOpen(!open)); function toggleSeries(key: string): void { - const visible = readVisible(); - if (visible.has(key)) visible.delete(key); - else visible.add(key); + // 곡선 기준(횡단/종단)은 라디오 — 하나를 고르면 그 기준 그래프만 전체 영역에 보인다. + const visible = applyLegendToggle(readVisible(), key); sessionStorage.setItem(VISIBLE_KEY, JSON.stringify([...visible])); onChanged(); } @@ -149,12 +165,14 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa return { handle, bar, + legendLayer, isOpen: () => open, setContext(next) { context = next; }, draw(params) { bar.replaceChildren(); + legendLayer.replaceChildren(); if (!open) return null; const note = (message: string): null => { const empty = document.createElement("span"); @@ -192,7 +210,8 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa params.onSelectStation, haulPlan, ); - bar.append( + legendLayer.style.top = `${params.legendTopPx}px`; + legendLayer.append( createMassHaulLegend(series, visible, toggleSeries, () => { resetBalloonOffsets(); onChanged(); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts index 2dcb3f8c..0b005b63 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -77,6 +77,10 @@ const HEIGHT_KEY = "b05-route-profile-height"; const TABLE_HEIGHT_KEY = "b05-route-profile-table-height"; /** 정보 라인을 뺀 본문 세로를 그래프 40% : 테이블 60%로 나눈다(4:6, 6이 테이블). */ const CHART_HEIGHT_RATIO = 0.4; +/** 유토곡선 펼침 시 종단:곡선 분할 비율(종단 몫). 경계 드래그로 조절, 세션 보존. */ +const MASS_SPLIT_KEY = "b05-route-profile-masshaul-split"; +const MASS_SPLIT_MIN = 0.25; +const MASS_SPLIT_MAX = 0.75; const MIN_CHART_HEIGHT = 100; /** 패널을 끌어 줄일 수 있는 하한(px) — 이보다 낮으면 그래프도 테이블도 못 읽는다. */ const MIN_PANEL_HEIGHT = 180; @@ -265,9 +269,9 @@ export function createRouteProfilePanel( const bodyWrap = document.createElement("div"); bodyWrap.className = "b05-route-profile__body-wrap"; bodyWrap.append(body, progress.root); - // 계획 유토곡선 — 2차 하단 슬라이드. 펼치면 도면 테이블 자리를 덮는다. + // 유토곡선 — 2차 하단 슬라이드. 펼치면 도면 테이블 자리를 덮는다. const massHaul = createRouteMassHaulPanel(() => draw()); - bodyWrap.append(massHaul.bar, massHaul.handle); + bodyWrap.append(massHaul.legendLayer, massHaul.bar, massHaul.handle); const content = document.createElement("div"); content.className = "b05-route-profile__content"; // 관 목록이 바뀌면 종단 테이블의 "배관" 구조물 라인도 같이 맞춘다(정본은 관 지점 파일). @@ -441,10 +445,14 @@ export function createRouteProfilePanel( fixedTableHeight = Math.round(available * (1 - CHART_HEIGHT_RATIO)); sessionStorage.setItem(TABLE_HEIGHT_KEY, String(fixedTableHeight)); } - // 유토곡선을 펼치면 종단 그래프와 곡선이 세로를 **1:1**로 나눈다(2026-08-03 사용자 지시) - // — 테이블용 고정 높이를 그대로 쓰면 곡선이 종단 그래프를 밀어낸다. + // 유토곡선을 펼치면 종단 그래프와 곡선이 세로를 나눈다. 기본 1:1(2026-08-03 사용자 지시), + // 두 그래프 사이 경계선을 끌어 비율을 바꿀 수 있고 세션 동안 유지된다. + const massSplit = Math.min( + MASS_SPLIT_MAX, + Math.max(MASS_SPLIT_MIN, Number(sessionStorage.getItem(MASS_SPLIT_KEY)) || 0.5), + ); const chartHeight = massHaul.isOpen() - ? Math.max(MIN_CHART_HEIGHT, Math.floor(available / 2)) + ? Math.max(MIN_CHART_HEIGHT, Math.floor(available * massSplit)) : alignment ? Math.max(MIN_CHART_HEIGHT, available - fixedTableHeight) : available; @@ -575,7 +583,10 @@ export function createRouteProfilePanel( // 좌우 여백에 합쳐 넘긴다 — 어긋나면 같은 측점이 두 그래프에서 다른 자리에 선다. padLeft: LONG_PAD.left + originOffset, padRight: LONG_PAD.right + originOffset, + // 축 선·눈금은 위 종단 그래프의 축과 같은 자리(62px)에 — 축이 두 개로 보이지 않게. + axisX: LONG_PAD.left, }, + legendTopPx: chartHeight + 6, stationInterval: stationIntervalM ?? 1, widthPx: width, heightPx: tableHeight, @@ -587,7 +598,35 @@ export function createRouteProfilePanel( canvas.style.height = `${available}px`; canvas.append(chartWrap); if (table) canvas.append(table); - if (massHaulChart) canvas.append(massHaulChart); + if (massHaulChart) { + // 종단 그래프와 유토곡선 사이 경계 — 세로로 끌어 두 그래프의 높이 배분을 바꾼다. + const splitBar = document.createElement("div"); + splitBar.className = "b05-profile__masshaul-split"; + splitBar.title = "끌어서 종단 그래프와 유토곡선의 높이 비율을 조절합니다."; + // 절대배치 — 흐름에 끼우면 6px만큼 세로가 넘쳐 곡선 아래가 잘린다. + splitBar.style.top = `${chartHeight - 3}px`; + splitBar.addEventListener("pointerdown", (event) => { + event.preventDefault(); + splitBar.setPointerCapture(event.pointerId); + const startY = event.clientY; + const startRatio = chartHeight / Math.max(available, 1); + const onMove = (move: PointerEvent): void => { + const ratio = startRatio + (move.clientY - startY) / Math.max(available, 1); + sessionStorage.setItem( + MASS_SPLIT_KEY, + String(Math.min(MASS_SPLIT_MAX, Math.max(MASS_SPLIT_MIN, ratio))), + ); + rebuild(); + }; + const onUp = (): void => { + splitBar.removeEventListener("pointermove", onMove); + splitBar.removeEventListener("pointerup", onUp); + }; + splitBar.addEventListener("pointermove", onMove); + splitBar.addEventListener("pointerup", onUp); + }); + canvas.append(splitBar, massHaulChart); + } body.replaceChildren(canvas); body.scrollLeft = scrollLeft; } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts index f0241186..956864df 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts @@ -212,7 +212,19 @@ function buildSegmentRows( x: (chainage: number) => number, fontPx: number, rowHeight: number, + /** 구조물(비정규) 측점 승격으로 생긴 변화점 chainage 집합(소수 3자리 키). */ + structureChainages: ReadonlySet, + /** 현재 선택된 측점 chainage — 구조물 파생 블록은 선택됐을 때만 값을 보인다. */ + selectedChainageM: number | null, ): HTMLElement[] { + // 구조물 배치로 갈라진 구간인지 — 양 끝 중 하나라도 구조물 변화점이면 파생 블록이다. + const isStructureSegment = (segment: AlignmentSegment): boolean => + structureChainages.has(segment.from_m.toFixed(3)) || + structureChainages.has(segment.to_m.toFixed(3)); + const touchesSelected = (segment: AlignmentSegment): boolean => + selectedChainageM !== null && + (Math.abs(segment.from_m - selectedChainageM) < 0.01 || + Math.abs(segment.to_m - selectedChainageM) < 0.01); return SEGMENT_ROWS.map((spec) => { const row = createRow("b05-profile-table__row--grade", spec.label, spec.unit); alignment.segments.forEach((segment) => { @@ -220,7 +232,24 @@ function buildSegmentRows( const span = x(segment.to_m) - left; const text = spec.cell(segment); const node = element("span", "b05-profile-table__segment", ""); - // 값은 항상 표기한다. 가로로 안 들어가면 90도로 세워 블록의 폭(span)·행 높이에 맞춰 + // 구조물 배치로 갈라진 좁은 구간 값은 처음부터 보이지 않는다 — 블록·경계선·툴팁만 남기고, + // 그 측점을 **선택했을 때** 하이라이트와 함께 값을 보인다(2026-08-03 사용자 지시). + const structural = isStructureSegment(segment); + const highlighted = structural && touchesSelected(segment); + if (structural) node.classList.add("is-structure"); + if (highlighted) node.classList.add("is-highlight"); + if (structural && !highlighted) { + node.style.left = `${left}px`; + node.style.width = `${span}px`; + node.title = + `${segment.from_m.toFixed(1)} ~ ${segment.to_m.toFixed(1)}m 직선 (구조물 구간) +` + + `연장 ${segment.length_m.toFixed(2)}m · 고저차 ${segment.height_m.toFixed(2)}m · ` + + `구배 ${segment.grade_percent.toFixed(2)}%`; + row.append(node); + return; + } + // 값은 표기한다. 가로로 안 들어가면 90도로 세워 블록의 폭(span)·행 높이에 맞춰 // 글자를 줄여 넣는다(작아도 무시 — 값이 아예 안 보이는 것보단 낫다). const value = element("span", "b05-profile-table__segment-value", text); if (!segmentTextFits(text, span, fontPx)) { @@ -480,7 +509,24 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement { table.style.setProperty("--b05-table-label-width", `${labelWidth}px`); table.style.setProperty("--b05-table-label-font", `${Math.max(8, labelFontSize)}px`); - table.append(...buildSegmentRows(alignment, x, fontSize, rowHeight)); + // 구조물(비정규) 측점 승격 변화점 = 사용자 변화점 중 규칙 측점과 겹치지 않는 것. + const stationKeys = new Set(alignment.stations.map((row) => row.chainage_m.toFixed(3))); + const structureChainages = new Set( + alignment.pvi + .filter((node) => node.source === "user" && !stationKeys.has(node.chainage_m.toFixed(3))) + .map((node) => node.chainage_m.toFixed(3)), + ); + const selectedIrregularEntry = options.irregularStations?.find( + (entry) => irregularStationId(entry.id) === options.selectedStationId, + ); + const selectedStationRow = options.selectedStationId + ? alignment.stations.find((row) => row.station_id === options.selectedStationId) + : undefined; + const selectedChainageM = + selectedIrregularEntry?.chainage_m ?? selectedStationRow?.chainage_m ?? null; + table.append( + ...buildSegmentRows(alignment, x, fontSize, rowHeight, structureChainages, selectedChainageM), + ); buildStationRows(alignment, stationInterval, display).forEach((spec, index) => { const row = createRow( `${spec.modifier ? `is-${spec.modifier}` : ""}${index === 0 ? " is-group-start" : ""}`, @@ -498,11 +544,9 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement { // 선택된 측점(규칙·비정규 공용)을 **값 열 오버레이**로 강조한다. // 규칙 측점은 종점 이동을 반영한 `centers[index]`, 비정규 측점은 `x(chainage)`를 중심으로 쓴다. - const selectedIrregular = options.irregularStations?.find( - (entry) => irregularStationId(entry.id) === options.selectedStationId, - ); - const selectedRegularIndex = options.selectedStationId - ? alignment.stations.findIndex((row) => row.station_id === options.selectedStationId) + const selectedIrregular = selectedIrregularEntry; + const selectedRegularIndex = selectedStationRow + ? alignment.stations.indexOf(selectedStationRow) : -1; if (selectedIrregular) { // 표는 그대로 두고 선택된 값만 위에 얹는다 — 이웃 셀을 숨기면 고를 때마다 표가 비어 diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index 34303bd2..2fa07c92 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -109,6 +109,7 @@ /* 그래프와 테이블을 같은 폭으로 쌓아 X축이 저절로 맞물리게 한다. */ .b05-profile__canvas { + position: relative; /* 유토곡선 경계 드래그 바(absolute)의 기준 */ display: flex; flex-direction: column; } @@ -1134,3 +1135,14 @@ text-overflow: ellipsis; white-space: nowrap; } + +/* 구조물(배관) 배치로 갈라진 구배 블록 — 평소엔 값 없이 윤곽만, 선택하면 강조와 함께 값 표시. */ +.b05-profile-table__segment.is-structure { + opacity: 0.55; +} + +.b05-profile-table__segment.is-structure.is-highlight { + opacity: 1; + background: color-mix(in srgb, var(--color-primary) 14%, transparent); + outline: 1px solid var(--color-primary); +} diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style_MassHaul.css b/B05_wf2_Route/B05_wf2_Route_UI_Style_MassHaul.css index 7111d0ce..3603a304 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style_MassHaul.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style_MassHaul.css @@ -21,12 +21,35 @@ background: var(--color-surface); } -/* 공용 범례는 B06에서 그래프 위에 절대배치로 띄우지만, 여기서는 막대 안에 줄로 눕힌다. */ -.b05-profile__masshaul-bar .b06-masshaul__legend { - position: static; - right: auto; - flex-wrap: wrap; - padding: var(--spacing-4) var(--spacing-16); +/* 범례 오버레이 — B06과 같은 문법으로 유토곡선 우상단에 절대배치한다(2026-08-03 지시). + 공용 범례(.b06-masshaul__legend)가 이미 absolute + right 배치라 top만 이 래퍼가 정한다. */ +.b05-profile__masshaul-legend { + position: absolute; + top: 0; + right: 0; + left: 0; + z-index: 3; + height: 0; + pointer-events: none; +} + +.b05-profile__masshaul-legend .b06-masshaul__legend { + pointer-events: auto; +} + +/* 종단 그래프와 유토곡선 사이 경계 — 세로 드래그로 높이 배분을 바꾼다. */ +.b05-profile__masshaul-split { + position: absolute; + left: 0; + right: 0; + height: 6px; + z-index: 2; + cursor: row-resize; + touch-action: none; +} + +.b05-profile__masshaul-split:hover { + background: color-mix(in srgb, var(--color-primary) 25%, transparent); } .b05-profile__masshaul-bar .b06-masshaul__summary { diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts index 63aaba81..b543e27c 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts @@ -33,9 +33,11 @@ import { longitudinalMinimumWidth, } from "./B06_wf3_ProfileCross_UI_Longitudinal"; import { + applyLegendToggle, computeMassHaulSeries, MASS_HAUL_BALANCE_KEY, MASS_HAUL_DEFAULT_VISIBLE, + normalizeVisibleBasis, type MassHaulSeries, } from "@util/common_util_mass_haul"; import { computeHaulPlan } from "@util/common_util_mass_haul_balance"; @@ -98,8 +100,10 @@ function readVisibleSeries(): Set { const raw = sessionStorage.getItem(MASS_HAUL_VISIBLE_KEY); if (!raw) return new Set(DEFAULT_VISIBLE_KEYS); const parsed: unknown = JSON.parse(raw); - // 전부 끈 상태(빈 배열)도 사용자의 선택이므로 기본값으로 되돌리지 않는다. - return Array.isArray(parsed) ? new Set(parsed.map(String)) : new Set(DEFAULT_VISIBLE_KEYS); + // 기준 키는 라디오라 항상 하나로 눌러 맞춘다(분배 레이어 켜짐 여부는 저장값 그대로). + return Array.isArray(parsed) + ? normalizeVisibleBasis(new Set(parsed.map(String))) + : new Set(DEFAULT_VISIBLE_KEYS); } catch { return new Set(DEFAULT_VISIBLE_KEYS); } @@ -423,8 +427,10 @@ export function createSectionView( /** 범례 버튼 — 곡선 하나를 켜고 끈다. 축은 전체 곡선 기준이라 여기서 움직이지 않는다. */ const toggleSeries = (key: string): void => { - if (visibleSeries.has(key)) visibleSeries.delete(key); - else visibleSeries.add(key); + // 곡선 기준(횡단/종단)은 라디오 — 하나를 고르면 그 기준으로 계산된 그래프만 보인다. + const next = applyLegendToggle(visibleSeries, key); + visibleSeries.clear(); + next.forEach((entry) => visibleSeries.add(entry)); sessionStorage.setItem(MASS_HAUL_VISIBLE_KEY, JSON.stringify([...visibleSeries])); drawPanel(); }; diff --git a/common_util/common_util_mass_haul.ts b/common_util/common_util_mass_haul.ts index df9da3e0..cbd0fdc1 100644 --- a/common_util/common_util_mass_haul.ts +++ b/common_util/common_util_mass_haul.ts @@ -135,6 +135,35 @@ export const MASS_HAUL_DEFAULT_VISIBLE: MassHaulBasis[] = ["cross"]; */ export const MASS_HAUL_BALANCE_KEY = "balance"; +/** + * 곡선 기준 버튼은 **라디오**다 — 횡단/종단 중 하나를 고르면 그 기준으로 계산된 그래프 + * 하나만 전체 영역에 보인다(2026-08-03 사용자 지시. 겹쳐 비교하던 이전 방식 폐기). + * 켜짐 집합에 기준 키가 없거나 여럿이면 순서상 앞선 기준 하나로 눌러 맞춘다 — + * 두 화면(B05/B06)이 같은 저장 키를 공유하므로 읽는 쪽마다 같은 규칙이어야 한다. + */ +export function normalizeVisibleBasis(visible: Set): Set { + const active = SERIES_BASES.filter((basis) => visible.has(basis)); + if (active.length === 1) return visible; + const next = new Set(visible); + SERIES_BASES.forEach((basis) => next.delete(basis)); + next.add(active[0] ?? SERIES_BASES[0]); + return next; +} + +/** 범례 클릭 하나를 적용한다 — 기준 키는 라디오 선택, 그 외(분배 레이어)는 켜고 끄기. */ +export function applyLegendToggle(visible: Set, key: string): Set { + const next = new Set(visible); + if ((SERIES_BASES as string[]).includes(key)) { + SERIES_BASES.forEach((basis) => next.delete(basis)); + next.add(key); + } else if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return normalizeVisibleBasis(next); +} + /** 환산계수가 비어 있거나 값이 이상하면 1.0으로 떨어뜨려 계산이 멈추지 않게 한다. */ function factorFor(conversion: EarthworkConversion, ground: GroundType): number { const factor = conversion?.[ground]?.compacted; diff --git a/common_util/common_util_mass_haul_view.ts b/common_util/common_util_mass_haul_view.ts index 09478876..ffe04228 100644 --- a/common_util/common_util_mass_haul_view.ts +++ b/common_util/common_util_mass_haul_view.ts @@ -33,6 +33,14 @@ export interface MassHaulAxis { maxChainageM: number; padLeft: number; padRight: number; + /** + * Y축 선·눈금을 그릴 x(px). 생략하면 `padLeft`. + * + * B05는 데이터 시작을 반 칸(originOffset) 들여쓰므로 `padLeft = 62 + originOffset`인데, + * 축까지 그 자리에 그리면 위 종단 그래프의 축(62px)과 어긋나 **축이 두 개**로 보인다 + * (2026-08-03 사용자 보고). 축은 여기(종단과 같은 자리)에, 데이터는 padLeft에 둔다. + */ + axisX?: number; } /** 축 눈금이 읽히는 최소 높이. 이보다 낮아지면 그래프가 뭉개진다. */ @@ -289,11 +297,15 @@ export function createMassHaulChart( // 종단면도와 완전히 같은 X 매핑 — 여백·최댓값 모두 호출한 쪽 종단 렌더러에서 받는다. const maxChainage = Math.max(axis.maxChainageM, 1); + const axisX = axis.axisX ?? axis.padLeft; const plotWidth = widthPx - axis.padLeft - axis.padRight; const plotHeight = heightPx - MASS_PAD_TOP - MASS_PAD_BOTTOM; const x = (chainage: number) => axis.padLeft + (chainage / maxChainage) * plotWidth; - const { min, max } = volumeRange(series); + // 기준 버튼이 라디오가 되면서(택1 표시) Y 범위도 **표시 중인 곡선**으로 잡는다 — + // 숨은 기준까지 합쳐 잡으면 선택한 그래프가 눌려 보인다. 아무것도 안 켰으면 전체로 폴백. + const rangeSource = series.filter((entry) => visibleKeys.has(entry.key)); + const { min, max } = volumeRange(rangeSource.length ? rangeSource : series); const span = Math.max(max - min, 1e-6); const y = (volume: number) => MASS_PAD_TOP + ((max - volume) / span) * plotHeight; @@ -304,14 +316,14 @@ export function createMassHaulChart( const value = max - ratio * span; svg.append( svgElement("line", { - x1: axis.padLeft, + x1: axisX, y1: gridY, x2: widthPx - axis.padRight, y2: gridY, class: "b06-chart__grid", }), svgText(formatVolume(value), { - x: axis.padLeft - 9, + x: axisX - 9, y: gridY + 4, "text-anchor": "end", class: "b06-chart__tick", @@ -391,14 +403,14 @@ export function createMassHaulChart( // Y 범위 계산(`volumeRange`)이 0을 항상 품으므로 이 선은 어떤 노선에서도 화면에 남는다. svg.append( svgElement("line", { - x1: axis.padLeft, + x1: axisX, y1: zeroY, x2: widthPx - axis.padRight, y2: zeroY, class: "b06-masshaul__zero", }), svgText("0", { - x: axis.padLeft - 9, + x: axisX - 9, y: zeroY + 4, "text-anchor": "end", class: "b06-chart__tick b06-masshaul__zero-tick", @@ -486,9 +498,9 @@ export function createMassHaulChart( svg.append( svgElement("line", { - x1: axis.padLeft, + x1: axisX, y1: MASS_PAD_TOP, - x2: axis.padLeft, + x2: axisX, y2: heightPx - MASS_PAD_BOTTOM, class: "b06-chart__axis", }),