From 2bce04227f846de610ff8102a412393c51feac3e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 24 Jul 2026 13:09:11 +0900 Subject: [PATCH] 260723_0 --- .../B05_wf2_Route_UI_Profile_Panel.ts | 96 ++++++++++++------- B05_wf2_Route/B05_wf2_Route_UI_Style.css | 5 +- .../B06_wf3_ProfileCross_UI_Longitudinal.ts | 12 ++- 3 files changed, 76 insertions(+), 37 deletions(-) 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 e7aeb20a..919c8235 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -124,41 +124,61 @@ function normalizedLongitudinal(data: LongitudinalSection): LongitudinalSection }; } -/** 종단면도 렌더러와 **같은** chainage → x(px) 매핑을 만든다 (테이블·버튼 정렬 기준). */ -function chainageMapper(data: LongitudinalSection, width: number): (chainage: number) => number { - const samples = normalizedLongitudinal(data).samples.filter( - (sample) => sample.valid !== false && Number.isFinite(sample.elevation_m ?? NaN), - ); - const maxChainage = Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1); - const plotWidth = width - LONG_PAD.left - LONG_PAD.right; - return (chainage: number) => LONG_PAD.left + (chainage / maxChainage) * plotWidth; -} - /** 측점 사이 여백 — 이웃 셀끼리 붙어 보이지 않게 띄운다. */ const CELL_GAP_PX = 2; -/** 이웃 측점과 겹치지 않는 테이블 셀 폭 (글자 크기를 정하는 기준이기도 하다). */ -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) - CELL_GAP_PX, 96)); +/** + * **측점 간격 기본값(px)** — 측점 한 칸의 고정 폭. + * + * 목표 글자 크기(12px)로 7자리 값(`3000.00`)이 잘리지 않는 크기다. 브라우저 폭에 맞춰 + * 늘였다 줄였다 하지 않고 이 값을 항상 유지하며, 남는 가로는 스크롤로 훑는다. 규칙 측점은 + * 정확히 이 간격으로 찍히고, 셀 폭·글자 크기도 이 값에서 나온다. + * + * 다음 세션의 **비정규 측점**(`+18`, `+15` 등)은 이 간격을 한 칸으로 보고 그 안에서 + * chainage 비례로 자리 잡는다 — 값 자체는 chainage×(간격/측점간격)로 매핑되므로 규칙 칸을 + * 넓히면 비정규 측점이 겹칠 여유도 함께 늘어난다. 그래서 기본값을 넉넉히 잡아 둔다. + */ +const STATION_SPACING_PX = tableCellWidthFor(TABLE_TARGET_FONT_PX) + CELL_GAP_PX; + +/** + * 0측점·종점을 좌우 축 프레임 안쪽으로 반 칸 밀어넣는 여백(px). + * + * 0측점을 이름표 열(labelWidth = LONG_PAD.left) 바로 위에 두면 셀의 왼쪽 절반이 이름표에 + * 가려진다. 반 칸을 밀어 0측점 셀 전체가 이름표 오른쪽으로 나오게 하고, 종점도 대칭으로 + * 오른쪽 끝에서 반 칸 띄운다. 그래프도 같은 오프셋(`originOffsetPx`)을 받아 X축을 맞춘다. + */ +const PROFILE_ORIGIN_OFFSET_PX = STATION_SPACING_PX / 2; + +/** 유효 표고 샘플 기준의 노선 최대 chainage(m). 그래프·테이블이 같은 값을 써야 X축이 맞물린다. */ +function maxChainageOf(data: LongitudinalSection): number { + const samples = normalizedLongitudinal(data).samples.filter( + (sample) => sample.valid !== false && Number.isFinite(sample.elevation_m ?? NaN), + ); + return Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1); +} + +/** 종단면도 렌더러와 **같은** chainage → x(px) 매핑을 만든다 (테이블·버튼 정렬 기준). */ +function chainageMapper( + data: LongitudinalSection, + width: number, + originOffset: number, +): (chainage: number) => number { + const maxChainage = maxChainageOf(data); + const plotWidth = width - LONG_PAD.left - LONG_PAD.right - 2 * originOffset; + return (chainage: number) => LONG_PAD.left + originOffset + (chainage / maxChainage) * plotWidth; } /** - * 측점 한 칸의 고정 폭. 목표 글자 크기(12px)로 7자리 값이 잘리지 않는 크기다. - * 화면 폭에 맞춰 늘였다 줄였다 하지 않고 이 값을 유지하고, 남는 가로는 스크롤로 훑는다. - */ -const STATION_COLUMN_PX = tableCellWidthFor(TABLE_TARGET_FONT_PX) + CELL_GAP_PX; - -/** - * 측점 칸 폭을 고정했을 때 필요한 캔버스 폭. + * 측점 간격을 `STATION_SPACING_PX`로 고정했을 때의 캔버스 폭. * - * 종단면도 렌더러의 최소 폭(`longitudinalMinimumWidth`)은 그래프 아래 **측점 라벨**만 - * 안 겹치면 되는 기준이라 열이 48px밖에 안 돼, 7자리 값(`3000.00`)을 담기엔 좁다. + * 브라우저 폭에 맞춰 늘리지 않는다 — 노선 전체를 `STATION_SPACING_PX / 측점간격(m)`의 + * 고정 배율(px/m)로 펼치고, 화면보다 길면 스크롤로 훑는다. 이렇게 해야 측점 간격·셀 폭· + * 글자 크기가 브라우저 크기와 무관하게 일정하게 유지된다. */ -function tableMinimumWidth(stationCount: number): number { - return LONG_PAD.left + LONG_PAD.right + Math.max(1, stationCount) * STATION_COLUMN_PX; +function fixedProfileWidth(data: LongitudinalSection, stationIntervalM: number): number { + const pxPerMeter = STATION_SPACING_PX / Math.max(stationIntervalM, 1e-6); + // 좌우 반 칸씩(= STATION_SPACING_PX) 여백을 더해 0측점·종점 셀이 프레임에 붙지 않게 한다. + return LONG_PAD.left + LONG_PAD.right + STATION_SPACING_PX + maxChainageOf(data) * pxPerMeter; } export function createRouteProfilePanel( @@ -291,16 +311,22 @@ export function createRouteProfilePanel( renderBalance(); const longitudinal = detail.longitudinal; - const minimumWidth = Math.max( - longitudinalMinimumWidth(longitudinal, stationInterval), - alignment ? tableMinimumWidth(longitudinal.stations.length) : 0, - ); - const width = Math.max(Math.max(1, body.clientWidth - 30), minimumWidth); + // 계획선(편집 가능) 상태에서는 측점 간격을 고정한다. 그 외(구버전·플레인 뷰)는 + // 예전처럼 화면 폭에 맞춰 펼친다. + const stationIntervalM = stationInterval ?? alignment?.policy.station_interval_m; + const originOffset = alignment ? PROFILE_ORIGIN_OFFSET_PX : 0; + const width = + alignment && stationIntervalM + ? fixedProfileWidth(longitudinal, stationIntervalM) + : Math.max( + Math.max(1, body.clientWidth - 15), + longitudinalMinimumWidth(longitudinal, stationInterval), + ); const canvas = document.createElement("div"); canvas.className = "b05-profile__canvas"; canvas.style.width = `${width}px`; - const x = chainageMapper(longitudinal, width); + const x = chainageMapper(longitudinal, width, originOffset); // 그래프 30% : 테이블 70% (상단 정보 라인은 본문 밖이라 애초에 빠져 있다). // 가로 스크롤바를 `overflow-x: scroll`로 항상 띄우므로 clientHeight에서 이미 빠져 있다. const available = Math.max(120, body.clientHeight); @@ -315,7 +341,8 @@ export function createRouteProfilePanel( stationInterval: stationInterval ?? alignment.policy.station_interval_m, width, height: tableHeight, - cellWidth: stationCellWidth(longitudinal, x), + // 측점 간격을 고정했으므로 셀 폭도 고정값으로 둔다(브라우저 폭과 무관하게 유지). + cellWidth: STATION_SPACING_PX - CELL_GAP_PX, // 이름표 열을 그래프 좌측 여백과 같은 폭으로 맞춰야 그래프 시작점이 가려지지 않는다. labelWidth: LONG_PAD.left, rowCount: TABLE_ROW_COUNT, @@ -343,6 +370,7 @@ export function createRouteProfilePanel( chartHeight, width, designProfiles, + originOffset, ), ); if (alignment) { diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index b9ef068a..8683f956 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -60,7 +60,10 @@ min-height: 0; overflow-x: scroll; overflow-y: hidden; - padding-inline: 15px; + /* 좌측 여백을 0으로 둔다. 여백이 있으면 sticky 이름표 열이 그 여백까지 못 덮어, + 가로 스크롤 시 셀 값이 이름표 왼쪽 틈으로 새어 보인다. 우측만 숨 돌릴 여백을 준다. */ + padding-left: 0; + padding-right: 15px; } /* 종단면도는 노선 전체 길이만큼 가로로 길어 스크롤바가 유일한 이동 수단이다. diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts index 8d6bea09..a2c841af 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts @@ -86,6 +86,11 @@ export function createLongitudinalProfile( heightPx = LONG_HEIGHT, minimumWidthPx = widthPx, designProfiles: DesignProfile[] = [], + /** + * 데이터(측점선·프로파일)를 그래프 축 프레임 안쪽으로 더 들여쓰는 좌우 여백(px). + * B05 도면 테이블과 정렬할 때 0측점을 이름표 열 바깥으로 밀어내는 데 쓴다(기본 0). + */ + originOffsetPx = 0, ): HTMLElement { const samples = data.samples.filter(validElevation); if (samples.length < 2) return emptyView(L("B06_Profile_View_NoLongitudinal")); @@ -113,12 +118,15 @@ export function createLongitudinalProfile( const rawMax = yScaleOptions?.globalMaxElevation ?? Math.max(...elevations); const elevationMid = (rawMin + rawMax) / 2; const exaggeration = Math.max(verticalExaggeration, 0.1); - const plotWidth = widthPx - LONG_PAD.left - LONG_PAD.right; + // 데이터 영역은 축 프레임(LONG_PAD)보다 originOffsetPx만큼 더 좁게 잡아, + // 0측점·종점이 좌우 끝에 딱 붙지 않고 반 칸씩 안으로 들어오게 한다. + const plotWidth = widthPx - LONG_PAD.left - LONG_PAD.right - 2 * originOffsetPx; const plotHeight = heightPx - LONG_PAD.top - LONG_PAD.bottom; const elevationSpan = yScaleOptions ? plotHeight / yScaleOptions.pixelsPerMeter : Math.max(rawMax - rawMin, 1); - const x = (chainage: number) => LONG_PAD.left + (chainage / maxChainage) * plotWidth; + const x = (chainage: number) => + LONG_PAD.left + originOffsetPx + (chainage / maxChainage) * plotWidth; const y = (elevation: number) => LONG_PAD.top + ((elevationMid + elevationSpan / 2 - elevation) / elevationSpan) * plotHeight; const stationInterval = configuredStationInterval ?? inferStationInterval(data.stations);