From f633dc0a4937f8a102ef589dc5ccdcd42cc10056 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 23 Jul 2026 19:31:52 +0900 Subject: [PATCH] 260722_2 --- B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts | 4 +- .../B05_wf2_Route_Engine_Grade_Alignment.py | 60 +++++----- B05_wf2_Route/B05_wf2_Route_Schema.py | 12 +- .../B05_wf2_Route_UI_Profile_Alignment.ts | 46 ++++---- .../B05_wf2_Route_UI_Profile_Edit.ts | 8 +- .../B05_wf2_Route_UI_Profile_Panel.ts | 61 +++++++--- .../B05_wf2_Route_UI_Profile_Table.ts | 109 +++++++++++------- B05_wf2_Route/B05_wf2_Route_UI_Style.css | 82 +++++++++---- config/config_system.py | 12 +- 9 files changed, 245 insertions(+), 149 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index d0ead034..a91ba765 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -187,10 +187,10 @@ export async function updateContourInterval( ); } -/** 종단 계획선 편집 델타 (자동 선형 대비 측점 계획고 델타 + 종단곡선 길이). */ +/** 종단 계획선 편집 델타 (자동 선형 대비 측점 계획고 델타 + 종단곡선 반경). */ export interface ProfileAlignmentEdits { station_offsets: Record; - curve_lengths: Record; + curve_radii: Record; } export interface ProfileAlignmentSaveResponse { diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Grade_Alignment.py b/B05_wf2_Route/B05_wf2_Route_Engine_Grade_Alignment.py index a59cefde..c2231797 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Grade_Alignment.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Grade_Alignment.py @@ -8,7 +8,7 @@ 기하 규칙 (사용자 확정 사항): - 변화점은 **기준 측점 위에만** 놓인다 → 곡선 중심이 측점 수직선상에 있다. - 종단곡선은 변화점 대칭 배치이며 좌우에 직선 구간이 반드시 남는다. - - 곡선 기본 길이 L = 측점간격 × `curve_length_ratio`, 반경 R = L / |A| 로 파생. + - 곡선 기본 **반경** R = 측점간격 × `curve_radius_ratio`, 길이 L = R × |A| 로 파생. 수치 최적화(직선 분할 DP·표고 결정)는 [[B05_wf2_Route_Engine_Grade_Solver]]가, 기준 해석과 오케스트레이션은 [[B05_wf2_Route_Engine_Grade]]가 담당한다. 이 모듈은 @@ -37,8 +37,7 @@ class AlignmentPolicy: """계획선 선형·편집 정책. config 기본값 위에 노선별 해석값을 얹는다.""" station_interval_m: float - curve_length_ratio: float - curve_length_min_m: float + curve_radius_ratio: float curve_tangent_max_ratio: float curve_skip_legal_exception: bool balance_tolerance_percent: float @@ -62,8 +61,7 @@ class AlignmentPolicy: config = FOREST_ROAD_PROFILE_ALIGNMENT return cls( station_interval_m=float(station_interval_m), - curve_length_ratio=float(config["curve_length_ratio"]), - curve_length_min_m=float(config["curve_length_min_m"]), + curve_radius_ratio=float(config["curve_radius_ratio"]), curve_tangent_max_ratio=float(config["curve_tangent_max_ratio"]), curve_skip_legal_exception=bool(config["curve_skip_legal_exception"]), balance_tolerance_percent=float(config["balance_tolerance_percent"]), @@ -82,11 +80,8 @@ class AlignmentPolicy: config = FOREST_ROAD_PROFILE_ALIGNMENT return cls( station_interval_m=float(payload.get("station_interval_m") or 20.0), - curve_length_ratio=float( - payload.get("curve_length_ratio", config["curve_length_ratio"]) - ), - curve_length_min_m=float( - payload.get("curve_length_min_m", config["curve_length_min_m"]) + curve_radius_ratio=float( + payload.get("curve_radius_ratio", config["curve_radius_ratio"]) ), curve_tangent_max_ratio=float( payload.get("curve_tangent_max_ratio", config["curve_tangent_max_ratio"]) @@ -109,19 +104,22 @@ class AlignmentPolicy: ) @property - def default_curve_length_m(self) -> float: - """측점간격 기준 기본 종단곡선 길이.""" - return max(self.curve_length_min_m, self.station_interval_m * self.curve_length_ratio) + def default_curve_radius_m(self) -> float: + """측점간격 기준 기본 종단곡선 반경. + + R을 1차 값으로 고정해야 계획고를 편집해도 곡선 반경이 흔들리지 않는다. + 곡선 길이는 L = R × |대수차| 로 따라 움직인다. + """ + return max(1e-6, self.station_interval_m * self.curve_radius_ratio) def as_dict(self) -> dict[str, Any]: """프론트엔드가 동일 기하를 재현하는 데 필요한 상수 묶음.""" return { "station_interval_m": self.station_interval_m, - "curve_length_ratio": self.curve_length_ratio, - "curve_length_min_m": self.curve_length_min_m, + "curve_radius_ratio": self.curve_radius_ratio, "curve_tangent_max_ratio": self.curve_tangent_max_ratio, "curve_skip_legal_exception": self.curve_skip_legal_exception, - "default_curve_length_m": self.default_curve_length_m, + "default_curve_radius_m": self.default_curve_radius_m, "balance_tolerance_percent": self.balance_tolerance_percent, "edit_step_m": self.edit_step_m, "grade_violation_policy": self.grade_violation_policy, @@ -174,14 +172,17 @@ def build_curves( pvi_s: np.ndarray, pvi_z: np.ndarray, policy: AlignmentPolicy, - curve_lengths: dict[str, float] | None = None, + curve_radii: dict[str, float] | None = None, ) -> tuple[list[dict[str, Any]], list[str]]: """각 변화점에 대칭 종단곡선을 삽입하고 곡선 제원을 만든다. + **반경 R이 1차 값**이고 곡선길이는 L = R × |대수차| 로 따라온다. 계획고를 편집하면 + 대수차가 바뀌므로 L은 변하지만 R은 지정한 값 그대로 유지된다. + 곡선 반쪽 길이는 짧은 쪽 인접 직선의 `curve_tangent_max_ratio` 이내로 제한해 - 좌우에 직선이 반드시 남게 한다(사용자가 R을 키워도 곡선끼리 겹치지 않는다). + 좌우에 직선이 반드시 남게 한다(R을 크게 넣어도 곡선끼리 겹치지 않는다). """ - overrides = curve_lengths or {} + overrides = curve_radii or {} spans = pvi_s[1:] - pvi_s[:-1] grades = (pvi_z[1:] - pvi_z[:-1]) / np.where(spans > 0, spans, 1.0) curves: list[dict[str, Any]] = [] @@ -201,11 +202,12 @@ def build_curves( omitted = skip_allowed and policy.curve_skip_legal_exception requested = overrides.get(key) try: - length = float(requested) if requested is not None else policy.default_curve_length_m + radius = float(requested) if requested is not None else policy.default_curve_radius_m except (TypeError, ValueError): - length = policy.default_curve_length_m - if not np.isfinite(length) or length <= 0: - length = policy.default_curve_length_m + radius = policy.default_curve_radius_m + if not np.isfinite(radius) or radius <= 0: + radius = policy.default_curve_radius_m + length = radius * abs(delta) half_limit = ( min(float(spans[index - 1]), float(spans[index])) * policy.curve_tangent_max_ratio ) @@ -214,8 +216,8 @@ def build_curves( continue if length / 2.0 - half > 1e-6: warnings.append( - f"chainage {chainage:.1f}m: 인접 직선이 짧아 종단곡선 길이를 " - f"{half * 2.0:.1f}m로 줄였습니다." + f"chainage {chainage:.1f}m: 인접 직선이 짧아 종단곡선 반경을 " + f"{half * 2.0 / abs(delta):.0f}m(길이 {half * 2.0:.1f}m)로 줄였습니다." ) length = half * 2.0 curves.append( @@ -353,14 +355,14 @@ def build_alignment( for key, value in (edits.get("station_offsets") or {}).items() if value is not None } - curve_lengths = { + curve_radii = { str(key): float(value) - for key, value in (edits.get("curve_lengths") or {}).items() + for key, value in (edits.get("curve_radii") or {}).items() if value is not None } pvi_s, pvi_z, sources = resolve_pvi(base_s, base_z, station_offsets) - curves, warnings = build_curves(pvi_s, pvi_z, policy, curve_lengths) + curves, warnings = build_curves(pvi_s, pvi_z, policy, curve_radii) segments = _segments(pvi_s, pvi_z) plan = evaluate(pvi_s, pvi_z, curves, chainage) difference = plan - ground @@ -420,7 +422,7 @@ def build_alignment( {"chainage_m": round(float(s), 6), "elevation_m": round(float(z), 6)} for s, z in zip(base_s, base_z) ], - "edits": {"station_offsets": station_offsets, "curve_lengths": curve_lengths}, + "edits": {"station_offsets": station_offsets, "curve_radii": curve_radii}, "pvi": pvi_rows, "segments": segments, "curves": [ diff --git a/B05_wf2_Route/B05_wf2_Route_Schema.py b/B05_wf2_Route/B05_wf2_Route_Schema.py index 61103063..6a1ff953 100644 --- a/B05_wf2_Route/B05_wf2_Route_Schema.py +++ b/B05_wf2_Route/B05_wf2_Route_Schema.py @@ -182,18 +182,18 @@ class ProfileAlignmentSaveRequest(BaseModel): route_id: int = Field(gt=0) # {chainage 문자열(소수 3자리): 자동 선형 대비 계획고 델타(m)} station_offsets: dict[str, float] = Field(default_factory=dict) - # {chainage 문자열(소수 3자리): 종단곡선 길이(m)}. 화면의 R 입력에서 역산된 값. - curve_lengths: dict[str, float] = Field(default_factory=dict) + # {chainage 문자열(소수 3자리): 종단곡선 반경(m)}. 길이 L은 R × |대수차| 로 파생된다. + curve_radii: dict[str, float] = Field(default_factory=dict) @model_validator(mode="after") def validate_edits(self) -> "ProfileAlignmentSaveRequest": - for chainage, length in self.curve_lengths.items(): - if length <= 0: - raise ValueError(f"종단곡선 길이는 0보다 커야 합니다 (chainage {chainage}).") + for chainage, radius in self.curve_radii.items(): + if radius <= 0: + raise ValueError(f"종단곡선 반경은 0보다 커야 합니다 (chainage {chainage}).") return self def edits(self) -> dict[str, Any]: - return {"station_offsets": self.station_offsets, "curve_lengths": self.curve_lengths} + return {"station_offsets": self.station_offsets, "curve_radii": self.curve_radii} class ProfileAlignmentSaveResponse(BaseModel): diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Alignment.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Alignment.ts index d520a5ac..a8194209 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Alignment.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Alignment.ts @@ -9,16 +9,16 @@ * 규칙: * - 변화점(PVI)은 기준 측점 위에만 놓인다 → 곡선 중심이 측점 수직선상에 있다. * - 종단곡선은 변화점 대칭이며 좌우에 직선이 반드시 남는다. + * - 반경 R이 1차 값이고 곡선길이 L = R × |대수차| 로 따라온다 (편집해도 R은 유지). * - 편집 델타는 항상 **자동 선형(base_pvi) 기준**이라 키를 지우면 정확히 원복된다. * ========================================================================== */ export interface AlignmentPolicy { station_interval_m: number; - curve_length_ratio: number; - curve_length_min_m: number; + curve_radius_ratio: number; curve_tangent_max_ratio: number; curve_skip_legal_exception: boolean; - default_curve_length_m: number; + default_curve_radius_m: number; balance_tolerance_percent: number; edit_step_m: number; grade_violation_policy: string; @@ -102,7 +102,8 @@ export interface AlignmentViolation { export interface AlignmentEdits { station_offsets: Record; - curve_lengths: Record; + /** 변화점별 종단곡선 반경(m). 지정하지 않으면 정책 기본 반경을 쓴다. */ + curve_radii: Record; } export interface ProfileAlignment { @@ -135,7 +136,7 @@ export function chainageKey(value: number): string { } export function emptyEdits(): AlignmentEdits { - return { station_offsets: {}, curve_lengths: {} }; + return { station_offsets: {}, curve_radii: {} }; } /** 오름차순 x 배열 위에서의 선형 보간 (범위 밖은 양 끝값으로 클램프). */ @@ -201,7 +202,7 @@ interface WorkingCurve extends AlignmentCurve { function buildCurves( pvi: AlignmentNode[], policy: AlignmentPolicy, - curveLengths: Record, + curveRadii: Record, warnings: string[], ): WorkingCurve[] { const curves: WorkingCurve[] = []; @@ -217,15 +218,16 @@ function buildCurves( const chainage = pvi[index].chainage_m; const key = chainageKey(chainage); const skipAllowed = !policy.paved && Math.abs(delta) <= skipDelta + 1e-12; - const requested = curveLengths[key]; - const desired = - Number.isFinite(requested) && requested > 0 ? requested : policy.default_curve_length_m; + const requested = curveRadii[key]; + const radius = + Number.isFinite(requested) && requested > 0 ? requested : policy.default_curve_radius_m; + const desired = radius * Math.abs(delta); const halfLimit = Math.min(spanLeft, spanRight) * policy.curve_tangent_max_ratio; const half = Math.min(desired / 2, halfLimit); if (half <= 1e-9) continue; if (desired / 2 - half > 1e-6) { warnings.push( - `${chainage.toFixed(1)}m: 인접 직선이 짧아 종단곡선 길이를 ${(half * 2).toFixed(1)}m로 줄였습니다.`, + `${chainage.toFixed(1)}m: 인접 직선이 짧아 종단곡선 반경을 ${((half * 2) / Math.abs(delta)).toFixed(0)}m로 줄였습니다.`, ); } const length = half * 2; @@ -290,7 +292,7 @@ export function buildAlignment(base: AlignmentBase, edits: 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_lengths, warnings); + const curves = buildCurves(nodes, base.policy, edits.curve_radii, warnings); const segments: AlignmentSegment[] = []; for (let index = 0; index < nodes.length - 1; index += 1) { @@ -472,25 +474,23 @@ export function shiftSegment( return { ...edits, station_offsets: offsets }; } -/** 곡선 행에서 R을 고치면 L = R × |A| 로 역산해 곡선 길이 override로 저장한다. */ +/** + * 곡선 행의 R 입력을 저장한다. R이 1차 값이므로 그대로 담고, 곡선길이는 + * L = R × |대수차| 로 매 렌더마다 다시 계산된다(계획고를 편집해도 R은 유지된다). + * 빈 값이면 키를 지워 정책 기본 반경으로 돌아간다. + */ export function setCurveRadius( edits: AlignmentEdits, curve: AlignmentCurve, radiusM: number, ): AlignmentEdits { const key = chainageKey(curve.chainage_m); - const deltaRatio = Math.abs(curve.delta_pct) / 100; - const curveLengths = { ...edits.curve_lengths }; - if (!Number.isFinite(radiusM) || radiusM <= 0 || deltaRatio < 1e-9) { - delete curveLengths[key]; - } else { - curveLengths[key] = Number((radiusM * deltaRatio).toFixed(6)); - } - return { ...edits, curve_lengths: curveLengths }; + const curveRadii = { ...edits.curve_radii }; + if (!Number.isFinite(radiusM) || radiusM <= 0) delete curveRadii[key]; + else curveRadii[key] = Number(radiusM.toFixed(6)); + return { ...edits, curve_radii: curveRadii }; } export function hasEdits(edits: AlignmentEdits): boolean { - return ( - Object.keys(edits.station_offsets).length > 0 || Object.keys(edits.curve_lengths).length > 0 - ); + return Object.keys(edits.station_offsets).length > 0 || Object.keys(edits.curve_radii).length > 0; } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Edit.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Edit.ts index ef4762a9..640e480e 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Edit.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Edit.ts @@ -77,7 +77,7 @@ function readDraft(storageKey: string): AlignmentEdits | null { const parsed = JSON.parse(raw) as Partial; return { station_offsets: parsed.station_offsets ?? {}, - curve_lengths: parsed.curve_lengths ?? {}, + curve_radii: parsed.curve_radii ?? {}, }; } catch { return null; @@ -130,10 +130,10 @@ export function createProfileEditStore( resetStation(chainageM) { const key = chainageKey(chainageM); const stationOffsets = { ...current.station_offsets }; - const curveLengths = { ...current.curve_lengths }; + const curveRadii = { ...current.curve_radii }; delete stationOffsets[key]; - delete curveLengths[key]; - commit({ station_offsets: stationOffsets, curve_lengths: curveLengths }); + delete curveRadii[key]; + commit({ station_offsets: stationOffsets, curve_radii: curveRadii }); }, resetAll() { commit(emptyEdits()); 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 c4607adf..b3ba3bd9 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -2,8 +2,9 @@ * B05_wf2_Route_UI_Profile_Panel.ts * 하단 종단면도 패널 — 그래프 + 도면 테이블 2단, 계획선 직접 편집. * - * 화면 높이의 40%를 쓰며, 그래프와 9행 도면 테이블이 **하나의 가로 스크롤러** 안에 + * 화면 높이의 60%를 쓰며, 그래프와 12행 도면 테이블이 **하나의 가로 스크롤러** 안에 * 같은 폭으로 쌓여 X축이 자동으로 맞물린다(스크롤 동기화 코드 불필요). + * 본문 세로는 그래프 30% : 테이블 70%로 나눈다. * * 편집은 전부 프론트에서 즉시 계산해 다시 그리고, 영속화는 [확정] 시점에 * `saveProfileAlignment()`로 편집 델타만 보낸다. @@ -40,15 +41,11 @@ import { createProfileTable } from "./B05_wf2_Route_UI_Profile_Table"; import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css"; const COLLAPSED_KEY = "b05-route-profile-collapsed"; -const HORIZONTAL_SCROLLBAR_HEIGHT = 16; -/** - * 그래프 높이는 패널을 키워도 이 값을 유지한다. 패널을 60vh로 넓힌 목적이 그래프 확대가 - * 아니라 12행 도면 테이블의 가독성 확보이므로, 늘어난 세로 공간은 테이블이 가져간다. - */ -const CHART_HEIGHT = 180; -const MIN_CHART_HEIGHT = 120; -/** 12행 × 최소 행높이. 이보다 좁아지면 그래프를 줄여서라도 테이블을 확보한다. */ -const MIN_TABLE_HEIGHT = 168; +/** 정보 라인을 뺀 본문 세로를 그래프 30% : 테이블 70%로 나눈다. */ +const CHART_HEIGHT_RATIO = 0.3; +const MIN_CHART_HEIGHT = 100; +/** 테이블 12행. 행 높이와 셀 폭에서 글자 크기를 정하는 데 쓴다. */ +const TABLE_ROW_COUNT = 12; function readAlignment(data: LongitudinalSection): ProfileAlignment | null { const candidate = data.profile_alignment as ProfileAlignment | undefined; @@ -115,6 +112,14 @@ function chainageMapper(data: LongitudinalSection, width: number): (chainage: nu return (chainage: number) => LONG_PAD.left + (chainage / maxChainage) * plotWidth; } +/** 이웃 측점과 겹치지 않는 테이블 셀 폭 (글자 크기를 정하는 기준이기도 하다). */ +function stationCellWidth(data: LongitudinalSection, x: (chainage: number) => number): number { + const stations = data.stations; + if (stations.length < 2) return 72; + const span = x(stations[stations.length - 1].chainage_m) - x(stations[0].chainage_m); + return Math.max(30, Math.min(span / (stations.length - 1) - 2, 96)); +} + export function createRouteProfilePanel( projectId: string, onSelectStation: (stationId: string) => void, @@ -158,7 +163,7 @@ export function createRouteProfilePanel( ], ["변화점", `${alignment.pvi.length} 개`], ["종단곡선", `${alignment.curves.filter((curve) => !curve.omitted).length} 개`], - ["기준 R", `${policy.default_curve_length_m.toFixed(1)} m 곡선길이`], + ["기본 R", `${policy.default_curve_radius_m.toFixed(1)} m`], ]; const editedCount = Object.keys(alignment.edits.station_offsets).length; if (editedCount) entries.push(["편집 측점", `${editedCount} 개`, "edited"]); @@ -243,23 +248,28 @@ export function createRouteProfilePanel( canvas.style.width = `${width}px`; const x = chainageMapper(longitudinal, width); + // 그래프 30% : 테이블 70% (상단 정보 라인은 본문 밖이라 애초에 빠져 있다). + // 가로 스크롤바를 `overflow-x: scroll`로 항상 띄우므로 clientHeight에서 이미 빠져 있다. + const available = Math.max(120, body.clientHeight); + const chartHeight = alignment + ? Math.max(MIN_CHART_HEIGHT, Math.round(available * CHART_HEIGHT_RATIO)) + : available; + const tableHeight = available - chartHeight; + const table = alignment ? createProfileTable({ alignment, stationInterval: stationInterval ?? alignment.policy.station_interval_m, width, + height: tableHeight, + cellWidth: stationCellWidth(longitudinal, x), + rowCount: TABLE_ROW_COUNT, x, onCurveRadiusChange: (curve, radius) => applyEdits(setCurveRadius(store.edits(), curve, radius ?? 0)), }) : null; - // 그래프는 고정 높이를 지키고 남는 세로 공간은 전부 테이블이 쓴다(행 높이가 늘어난다). - const available = body.clientHeight - HORIZONTAL_SCROLLBAR_HEIGHT; - const chartHeight = table - ? Math.max(MIN_CHART_HEIGHT, Math.min(CHART_HEIGHT, available - MIN_TABLE_HEIGHT)) - : Math.max(MIN_CHART_HEIGHT, available); - const chartWrap = document.createElement("div"); chartWrap.className = "b05-profile__chart"; chartWrap.style.height = `${chartHeight}px`; @@ -295,12 +305,29 @@ export function createRouteProfilePanel( }), ); } + canvas.style.height = `${available}px`; canvas.append(chartWrap); if (table) canvas.append(table); body.replaceChildren(canvas); body.scrollLeft = scrollLeft; } + // 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도 + // 노선을 훑을 수 있게 한다 (Shift+휠은 브라우저 기본 가로 스크롤이라 그대로 둔다). + body.addEventListener( + "wheel", + (event) => { + if (event.shiftKey || event.deltaY === 0) return; + const delta = event.deltaY; + const limit = body.scrollWidth - body.clientWidth; + if (limit <= 0) return; + if ((delta < 0 && body.scrollLeft <= 0) || (delta > 0 && body.scrollLeft >= limit)) return; + body.scrollLeft += delta; + event.preventDefault(); + }, + { passive: false }, + ); + const resizeObserver = new ResizeObserver(() => { if ( body.clientWidth <= 0 || 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 fc934ca5..5e4b1f67 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts @@ -4,8 +4,10 @@ * * 실무 종단면도 좌측 하단 표를 그대로 옮긴 구성이다. 구배와 곡선은 단일값 행이 아니라 * 도면에서도 여러 줄로 찍히므로 각각 물리적인 행으로 분리했다. - * - 구배: `L=` / `H=` / `S=` 3행 (S행에 변화점 중앙종거를 함께 얹는다) - * - 곡선: `L=` / `R=` 2행 (R만 입력 가능, 고치면 L을 역산한다) + * - 구배: 연장 / 고저차 / 기울기 3행. 블록은 곡선 구간을 뺀 실제 직선부만 덮는다. + * - 곡선: 곡선길이 / 반경 2행. 반경 R만 입력 가능하고 L = R × |대수차| 로 따라온다. + * + * 값에는 `L=` 같은 접두를 붙이지 않는다 — 행 이름표가 이미 항목과 단위를 말해준다. * * 셀은 종단면도 그래프와 **같은 X 매핑**으로 절대 배치되므로 측점 수직선과 맞물린다. * ========================================================================== */ @@ -21,6 +23,11 @@ export interface ProfileTableOptions { alignment: ProfileAlignment; stationInterval: number; width: number; + /** 테이블 전체 높이(px). 행 수로 나눈 값이 글자 크기 기준이 된다. */ + height: number; + /** 이웃 측점과 겹치지 않는 셀 폭(px). */ + cellWidth: number; + rowCount: number; /** 종단면도와 공유하는 chainage → x(px) 매핑. */ x: (chainageM: number) => number; onCurveRadiusChange: (curve: AlignmentCurve, radiusM: number | null) => void; @@ -34,12 +41,28 @@ interface StationRowSpec { interface SegmentRowSpec { label: string; + unit: string; cell: (segment: AlignmentSegment) => string; } -const CELL_WIDTH = 56; /** 구간 블록이 이 폭보다 좁으면 글자가 겹쳐 읽을 수 없으므로 생략한다. */ const MIN_SEGMENT_WIDTH = 32; +const FONT_MIN_PX = 9; +const FONT_MAX_PX = 16; +/** 행 높이 대비 글자 크기 비율 (위아래 여백 확보). */ +const FONT_PER_ROW_HEIGHT = 0.52; +/** 셀 폭 대비 글자 크기 비율 — "409.96"(6자) + `-` 여유가 잘리지 않는 값. */ +const FONT_PER_CELL_WIDTH = 0.145; + +/** + * 행 높이와 셀 폭 중 빡빡한 쪽에 글자 크기를 맞춘다. + * 패널을 키우면 행이 두꺼워지며 글자도 같이 커지고, 측점이 촘촘해 셀이 좁아지면 + * 가로가 먼저 한계에 걸려 글자가 작아진다. + */ +function fitFontSize(rowHeight: number, cellWidth: number): number { + const fitted = Math.min(rowHeight * FONT_PER_ROW_HEIGHT, cellWidth * FONT_PER_CELL_WIDTH); + return Math.round(Math.max(FONT_MIN_PX, Math.min(FONT_MAX_PX, fitted))); +} function element(tag: string, className: string, text?: string): HTMLElement { const node = document.createElement(tag); @@ -50,8 +73,7 @@ function element(tag: string, className: string, text?: string): HTMLElement { /** 절대 배치 셀: 측점 x를 중심으로 좌우 대칭 배치한다. */ function placeCell(row: HTMLElement, centerX: number, node: HTMLElement): void { - node.style.left = `${centerX - CELL_WIDTH / 2}px`; - node.style.width = `${CELL_WIDTH}px`; + node.style.left = `${centerX}px`; row.append(node); } @@ -61,11 +83,11 @@ function createRow(className: string, label: string): HTMLElement { return row; } -/** 도면의 구배 블록 3행: 구간 연장 / 고저차 / 기울기. */ +/** 도면의 구배 블록 3행: 구간 연장(m) / 고저차(m) / 기울기(%). 단위는 행 이름표가 대신한다. */ const SEGMENT_ROWS: SegmentRowSpec[] = [ - { label: "구배 L", cell: (segment) => `L=${segment.length_m.toFixed(2)}m` }, - { label: "구배 H", cell: (segment) => `H=${segment.height_m.toFixed(2)}m` }, - { label: "구배 S", cell: (segment) => `S=${segment.grade_percent.toFixed(2)}%` }, + { label: "구배 L", unit: "m", cell: (segment) => segment.length_m.toFixed(2) }, + { label: "구배 H", unit: "m", cell: (segment) => segment.height_m.toFixed(2) }, + { label: "구배 S", unit: "%", cell: (segment) => segment.grade_percent.toFixed(2) }, ]; function buildStationRows(alignment: ProfileAlignment, interval: number): StationRowSpec[] { @@ -94,61 +116,61 @@ function buildStationRows(alignment: ProfileAlignment, interval: number): Statio } /** - * 구배 3행. 각 행은 변화점 사이 구간 전체를 덮는 블록으로 그린다. - * S행에는 변화점 중앙종거를 함께 얹는다 — 도면 구배 행의 소수값(0.56, -0.75 …)이 이 값이다(|A|·L/8). + * 구배 3행. 각 블록은 **곡선 구간(BVC~EVC)을 뺀 실제 직선 부분**만 덮는다. + * 변화점 자리는 곡선이 차지하므로 그 구간에 구배를 적어 두면 존재하지 않는 직선의 + * 값을 읽는 셈이 된다. 값(L/H/S) 자체는 도면 관례대로 변화점 사이 기준이다. */ function buildSegmentRows( alignment: ProfileAlignment, x: (chainage: number) => number, ): HTMLElement[] { - return SEGMENT_ROWS.map((spec, rowIndex) => { - const row = createRow("b05-profile-table__row--grade", spec.label); + const curveByPvi = new Map( + alignment.curves.filter((curve) => !curve.omitted).map((curve) => [curve.pvi_index, curve]), + ); + return SEGMENT_ROWS.map((spec) => { + const row = createRow("b05-profile-table__row--grade", `${spec.label} (${spec.unit})`); alignment.segments.forEach((segment) => { - const left = x(segment.from_m); - const span = Math.abs(x(segment.to_m) - left); + const startCurve = curveByPvi.get(segment.index); + const endCurve = curveByPvi.get(segment.index + 1); + const left = x(startCurve ? startCurve.evc_m : segment.from_m); + const span = x(endCurve ? endCurve.bvc_m : segment.to_m) - left; if (span < MIN_SEGMENT_WIDTH) return; const node = element("span", "b05-profile-table__segment", spec.cell(segment)); node.style.left = `${left}px`; node.style.width = `${span}px`; + node.title = + `${segment.from_m.toFixed(1)} ~ ${segment.to_m.toFixed(1)}m 직선\n` + + `연장 ${segment.length_m.toFixed(2)}m · 고저차 ${segment.height_m.toFixed(2)}m · ` + + `구배 ${segment.grade_percent.toFixed(2)}%`; if (Math.abs(segment.grade_percent) > alignment.policy.max_grade_pct + 1e-6) { node.classList.add("is-violation"); - node.title = `종단기울기 ${segment.grade_percent.toFixed(2)}%가 기준 ${alignment.policy.max_grade_pct.toFixed(1)}%를 초과합니다.`; + node.title += `\n⚠ 기준 ${alignment.policy.max_grade_pct.toFixed(1)}% 초과`; } row.append(node); }); - // 중앙종거는 기울기 변화의 결과이므로 마지막 S행에만 표기한다. - if (rowIndex === SEGMENT_ROWS.length - 1) { - alignment.curves.forEach((curve) => { - const node = element( - "span", - "b05-profile-table__ordinate", - (curve.delta_pct >= 0 ? "" : "-") + curve.middle_ordinate_m.toFixed(2), - ); - node.title = `중앙종거 (대수차 ${curve.delta_pct.toFixed(2)}% × L ${curve.l_m.toFixed(1)}m / 8)`; - placeCell(row, x(curve.chainage_m), node); - }); - } return row; }); } function curveTitle(curve: AlignmentCurve): string { return ( - `K=${curve.k.toFixed(2)} A=${curve.delta_pct.toFixed(2)}%\n` + + `종단곡선 R=${curve.r_m.toFixed(1)}m · L=${curve.l_m.toFixed(2)}m (L = R × |대수차|)\n` + + `대수차 A=${curve.delta_pct.toFixed(2)}% K=${curve.k.toFixed(2)} ` + + `중앙종거 ${curve.middle_ordinate_m.toFixed(3)}m\n` + `BVC=${curve.bvc_m.toFixed(1)}m (EL ${curve.bvc_elevation_m.toFixed(2)})\n` + `EVC=${curve.evc_m.toFixed(1)}m (EL ${curve.evc_elevation_m.toFixed(2)})` + (curve.omit_reason ? `\n${curve.omit_reason}` : "") ); } -/** 곡선 2행: 곡선길이 L(파생) / 반경 R(입력). */ +/** 곡선 2행: 반경 R(입력·1차 값) / 곡선길이 L(= R × |대수차| 파생). */ function buildCurveRows(options: ProfileTableOptions): HTMLElement[] { const { alignment, x, onCurveRadiusChange } = options; - const lengthRow = createRow("b05-profile-table__row--curve", "곡선 L"); - const radiusRow = createRow("b05-profile-table__row--curve", "곡선 R"); + const lengthRow = createRow("b05-profile-table__row--curve is-group-start", "곡선 L (m)"); + const radiusRow = createRow("b05-profile-table__row--curve", "곡선 R (m)"); alignment.curves.forEach((curve) => { - const lengthCell = element("span", "b05-profile-table__curve", `L=${curve.l_m.toFixed(2)}`); + const lengthCell = element("span", "b05-profile-table__curve", curve.l_m.toFixed(2)); lengthCell.title = curveTitle(curve); const radiusCell = element("span", "b05-profile-table__curve"); @@ -158,7 +180,7 @@ function buildCurveRows(options: ProfileTableOptions): HTMLElement[] { input.min = "1"; input.className = "b05-profile-table__radius"; input.value = curve.r_m.toFixed(1); - input.title = `종단곡선 반경 R (m) — 수정하면 L = R × |A| 로 역산합니다.\n${curveTitle(curve)}`; + input.title = `종단곡선 반경 R (m) — 이 값이 기준이고 곡선길이 L이 따라 계산됩니다.\n${curveTitle(curve)}`; input.addEventListener("change", () => { const parsed = Number.parseFloat(input.value); onCurveRadiusChange(curve, Number.isFinite(parsed) && parsed > 0 ? parsed : null); @@ -176,15 +198,24 @@ function buildCurveRows(options: ProfileTableOptions): HTMLElement[] { } export function createProfileTable(options: ProfileTableOptions): HTMLElement { - const { alignment, stationInterval, width, x } = options; + const { alignment, stationInterval, width, height, cellWidth, rowCount, x } = options; const table = element("div", "b05-profile-table"); + const fontSize = fitFontSize(height / Math.max(rowCount, 1), cellWidth); table.style.width = `${width}px`; + table.style.height = `${height}px`; + table.style.setProperty("--b05-table-font", `${fontSize}px`); + table.style.setProperty("--b05-table-cell-width", `${cellWidth}px`); + // 행 이름표는 가장 긴 "구배 L (m)"이 잘리지 않을 만큼만 차지한다. + table.style.setProperty("--b05-table-label-width", `${Math.round(fontSize * 5.2 + 14)}px`); table.append(...buildSegmentRows(alignment, x)); - buildStationRows(alignment, stationInterval).forEach((spec) => { - const row = createRow(spec.modifier ? `is-${spec.modifier}` : "", spec.label); - alignment.stations.forEach((station, index) => { - const value = spec.cell(index); + buildStationRows(alignment, stationInterval).forEach((spec, index) => { + const row = createRow( + `${spec.modifier ? `is-${spec.modifier}` : ""}${index === 0 ? " is-group-start" : ""}`, + spec.label, + ); + alignment.stations.forEach((station, stationIndex) => { + const value = spec.cell(stationIndex); if (!value) return; placeCell(row, x(station.chainage_m), element("span", "b05-profile-table__cell", value)); }); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index 983e5141..b2a5c5c0 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -58,16 +58,43 @@ box-sizing: border-box; flex: 1 1 auto; min-height: 0; - overflow-x: auto; + overflow-x: scroll; overflow-y: hidden; padding-inline: 15px; } +/* 종단면도는 노선 전체 길이만큼 가로로 길어 스크롤바가 유일한 이동 수단이다. + 전역 스크롤바(8px, thumb 불투명도 12%)는 가로로 쓰기엔 너무 옅어 잡히지 않으므로 + 이 패널에서만 더 두껍고 대비가 분명한 막대로 덮어쓴다. */ +.b05-route-profile__body { + scrollbar-color: var(--color-text-muted, var(--color-plum-velvet)) var(--color-surface); + scrollbar-width: auto; +} + +.b05-route-profile__body::-webkit-scrollbar { + height: 12px; +} + +.b05-route-profile__body::-webkit-scrollbar-track { + border-top: 1px solid var(--color-border); + background-color: var(--color-surface); +} + +.b05-route-profile__body::-webkit-scrollbar-thumb { + border: 2px solid transparent; + border-radius: var(--radius-pills); + background-color: color-mix(in srgb, var(--color-text) 45%, transparent); + background-clip: padding-box; +} + +.b05-route-profile__body::-webkit-scrollbar-thumb:hover { + background-color: color-mix(in srgb, var(--color-text) 65%, transparent); +} + /* 그래프와 테이블을 같은 폭으로 쌓아 X축이 저절로 맞물리게 한다. */ .b05-profile__canvas { display: flex; flex-direction: column; - height: 100%; } .b05-profile__chart { @@ -338,30 +365,36 @@ .b05-profile-table { position: relative; display: flex; - flex: 1 1 auto; + flex: 0 0 auto; flex-direction: column; min-height: 0; - border-top: 1px solid var(--color-border); + border-top: 2px solid var(--color-text-muted, var(--color-plum-velvet)); color: var(--color-text-body); - font-size: 10px; + /* 행 높이와 셀 폭에 맞춰 렌더러가 계산해 넣는다 (createProfileTable). */ + font-size: var(--b05-table-font, 11px); line-height: 1.1; user-select: none; } -/* 12개 행이 남는 세로 공간을 균등하게 나눠 갖는다 (패널을 키우면 행이 두꺼워진다). */ +/* 12개 행이 테이블 세로를 균등하게 나눠 갖는다 (패널을 키우면 행이 두꺼워진다). */ .b05-profile-table__row { position: relative; flex: 1 1 0; - min-height: 14px; - border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent); + min-height: 12px; + border-bottom: 1px solid var(--color-border); white-space: nowrap; } -.b05-profile-table__row--grade { - background: color-mix(in srgb, var(--color-surface) 40%, transparent); +/* 구배 / 측점값 / 곡선 묶음의 시작 행은 굵은 선으로 그룹을 구분한다. */ +.b05-profile-table__row.is-group-start { + border-top: 2px solid var(--color-text-muted, var(--color-plum-velvet)); } -.b05-profile-table__row--curve:last-child { +.b05-profile-table__row--grade { + background: color-mix(in srgb, var(--color-surface) 55%, transparent); +} + +.b05-profile-table__row:last-child { border-bottom: 0; } @@ -372,17 +405,16 @@ display: inline-flex; align-items: center; box-sizing: border-box; - width: 56px; + width: var(--b05-table-label-width, 60px); height: 100%; padding-inline: var(--spacing-4); - border-right: 1px solid var(--color-border); + border-right: 2px solid var(--color-text-muted, var(--color-plum-velvet)); background: var(--color-surface-raised); - color: var(--color-text-muted, var(--color-plum-velvet)); + color: var(--color-text); font-weight: var(--font-weight-medium); } .b05-profile-table__cell, -.b05-profile-table__ordinate, .b05-profile-table__segment, .b05-profile-table__curve { position: absolute; @@ -394,6 +426,13 @@ overflow: hidden; } +/* 측점 값 셀은 측점 수직선을 중심으로 좌우 대칭 배치된다. */ +.b05-profile-table__cell, +.b05-profile-table__curve { + width: var(--b05-table-cell-width, 56px); + transform: translateX(-50%); +} + .b05-profile-table__row.is-cut .b05-profile-table__cell { color: rgb(220 38 38); } @@ -410,8 +449,8 @@ /* 구간 블록: 변화점 사이 직선 전체를 덮어 어느 구간의 값인지 한눈에 보이게 한다. */ .b05-profile-table__segment { box-sizing: border-box; - border-left: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); - border-right: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); + border-left: 1px solid var(--color-border); + border-right: 1px solid var(--color-border); } .b05-profile-table__segment.is-violation { @@ -419,12 +458,6 @@ color: rgb(180 83 9); } -.b05-profile-table__ordinate { - z-index: 2; - background: var(--color-surface-raised); - color: var(--color-text-muted, var(--color-plum-velvet)); -} - .b05-profile-table__curve { z-index: 2; background: var(--color-surface-raised); @@ -441,12 +474,13 @@ .b05-profile-table__radius { box-sizing: border-box; width: 100%; + height: 90%; padding: 0 1px; border: 1px solid var(--color-border); border-radius: 2px; background: var(--color-surface); color: var(--color-text-body); - font-size: 10px; + font-size: inherit; text-align: center; } diff --git a/config/config_system.py b/config/config_system.py index 3097b928..bccf8f5f 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -338,11 +338,13 @@ FOREST_ROAD_PROFILE_ALIGNMENT = { # 5% 미만이면 지반 추종이 왜곡되어 불필요한 변화점이 늘고, 15%를 넘으면 # 사토·객토 운반 물량 부담이 커진다. 10%를 실무 균형점으로 둔다. "balance_tolerance_percent": 10.0, - # 종단곡선 기본 길이 = 측점간격 × 이 비율 (변화점 대칭 배치). - # 측점간격 20m → 곡선 8m가 변화점 좌우 ±4m를 점유하고 나머지 12m는 직선이다. - # 곡선반경 R은 길이 L과 기울기 대수차 A로부터 R = L / |A| 로 파생 표기한다. - "curve_length_ratio": 0.40, - "curve_length_min_m": 2.0, + # 종단곡선 기본 **반경** R = 측점간격 × 이 비율 (변화점 대칭 배치). + # R을 1차 값으로 두어야 계획고를 편집해도 R이 흔들리지 않는다(곡선길이 L이 대신 변한다). + # L = R × |기울기 대수차 A(비율)| + # 주의: 측점간격 20m 기준 R=8m이면 A=5%일 때 L=0.4m, A=15%일 때 L=1.2m로 + # 도면상 곡선이 거의 드러나지 않는다. 곡선을 뚜렷하게 보려면 이 비율을 크게 올린다 + # (예: 20.0 → R=400m, A=10%일 때 L=40m). + "curve_radius_ratio": 0.40, # 인접 직선 길이 대비 곡선 반쪽이 점유할 수 있는 최대 비율. # 0.45면 짧은 쪽 직선의 55%가 항상 직선으로 남아 좌우 곡선이 겹치지 않는다. "curve_tangent_max_ratio": 0.45,