From b51c8875499e1f5cac0afec0865dbfda7df82166 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 18:14:59 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05/B06):=20=EC=A2=85=EB=8B=A8=20=EA=B7=B8?= =?UTF-8?q?=EB=9E=98=ED=94=84=20=EC=A4=8C=20=EB=B2=84=ED=8A=BC=20=EB=8B=A8?= =?UTF-8?q?=EC=88=9C=ED=99=94=20+=20=EC=84=B8=EB=A1=9C=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 줌 버튼을 셋(줌인·줌아웃·초기화)으로 줄임. 세로 배율·창 이동 버튼 제거 - 배율 1 = 기본값이자 축소 한계 — 한계에 닿은 버튼은 흐리게 죽임 - 세로는 보이는 누가거리 구간의 지반·계획선 범위로 자동(공통 함수 windowElevationRange, B05·B06 종단이 함께 씀). 스크롤이 멈춘 뒤 0.16초에 갱신 - 계획고 편집 버튼을 누르고 있는 동안 Y 축 고정, 손을 떼면 다시 맞춤 - 유토곡선 Y 도 같은 창 기준(전 구간 ±200㎥ 고정 해제) - B06 종단이 쓰던 공통 Y 스케일(calculateYScale) 제거 — 횡단 카드는 원래 안 쓰던 값 Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_Profile_Panel.ts | 44 +++++++++++++- B05_Profile/B05_Profile_UI_Profile_Render.ts | 41 +++++++++++-- B05_Profile/B05_Profile_UI_Profile_Zoom.ts | 59 ++++++++----------- B05_Profile/B05_Profile_UI_Style_Table.css | 8 ++- B06_Section/B06_Section_UI_Longitudinal.ts | 12 +++- B06_Section/B06_Section_UI_Section_Common.ts | 41 +++++++++++++ B06_Section/B06_Section_UI_Section_View.ts | 50 ++++++++++++++-- .../B06_Section_UI_Section_View_MassHaul.ts | 3 + common_util/common_util_mass_haul_view.ts | 45 +++++++++++++- 9 files changed, 249 insertions(+), 54 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index 23da8778..a2580a54 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -335,9 +335,35 @@ export function createRouteProfilePanel( }); } - /* 줌·Y레인지 조작구 — 상태를 페이지가 들고 있어 편집·재계산으로 다시 그려도 유지된다. */ + /* 줌 조작구(가로 배율) — 상태를 페이지가 들고 있어 편집·재계산으로 다시 그려도 유지된다. + 세로는 보이는 구간에 맞춰 자동이라 사람이 맞출 것이 없다(2026-09-04 사용자 확정). */ const profileZoom = createProfileZoom(() => draw()); + /** 세로 자동 맞춤이 지금 쓰는 Y 창. 계획고를 끄는 동안에는 이 값을 붙잡는다. */ + let elevationWindow: { min: number; max: number } | undefined; + /** 계획고 편집 버튼(▲▼)을 누르고 있는 중인가 — 그동안 Y 축을 고정한다. */ + let heightEditing = false; + const holdElevationRange = ( + next: { min: number; max: number } | null, + ): { min: number; max: number } | undefined => { + if (heightEditing) return elevationWindow; + elevationWindow = next ?? undefined; + return elevationWindow; + }; + // 끌어 올리는 동안 축까지 따라 움직이면 조작 감각이 깨진다 — 손을 뗀 뒤 한 번만 다시 맞춘다. + body.addEventListener("pointerdown", (event) => { + if (!(event.target as HTMLElement).closest(".b05-profile-edit__btn")) return; + heightEditing = true; + const release = (): void => { + heightEditing = false; + window.removeEventListener("pointerup", release); + window.removeEventListener("pointercancel", release); + draw(); + }; + window.addEventListener("pointerup", release); + window.addEventListener("pointercancel", release); + }); + /* [직선화]·[쉬프트]·되돌리기·방향키 배선은 `_Panel_Tools` 로 뺐다(700줄 한계). */ const { tools, history, handleToolPick } = createPanelTools({ root, @@ -474,6 +500,7 @@ export function createRouteProfilePanel( applyEdits, handleToolPick, zoom: profileZoom.state, + holdElevationRange, toolActive: () => tools.mode() !== "none", selectedRuns: () => tools.selectedRuns(), stationIdAtStructure, @@ -481,6 +508,21 @@ export function createRouteProfilePanel( }); } + /** 가로 스크롤이 이만큼 멈춰 있으면 보이는 구간이 정해진 것으로 보고 세로를 다시 맞춘다. */ + const SCROLL_SETTLE_MS = 160; + /** 마지막으로 세로를 맞춘 가로 위치 — 같은 자리면 다시 그리지 않는다(재구성 되먹임 차단). */ + let settledScrollLeft = 0; + let scrollSettleTimer = 0; + // 스크롤하는 내내 축이 출렁이면 어지럽다 — 멈춘 뒤에 한 번만 다시 맞춘다(2026-09-04). + body.addEventListener("scroll", () => { + window.clearTimeout(scrollSettleTimer); + scrollSettleTimer = window.setTimeout(() => { + if (heightEditing || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return; + settledScrollLeft = body.scrollLeft; + draw(); + }, SCROLL_SETTLE_MS); + }); + // 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도 // 노선을 훑을 수 있게 한다 (Shift+휠은 브라우저 기본 가로 스크롤이라 그대로 둔다). body.addEventListener( diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 383a4cfe..6d4662c3 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -12,7 +12,11 @@ import { createLongitudinalProfile, longitudinalMinimumWidth, } from "../B06_Section/B06_Section_UI_Longitudinal"; -import { hasStaleDesigns, LONG_PAD } from "../B06_Section/B06_Section_UI_Section_Common"; +import { + hasStaleDesigns, + LONG_PAD, + windowElevationRange, +} from "../B06_Section/B06_Section_UI_Section_Common"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; import { normalizedLongitudinal, toDesignProfile } from "./B05_Profile_UI_Profile_Data"; import { @@ -84,8 +88,15 @@ export interface ProfileRenderContext { applyEdits: (next: AlignmentEdits) => void; /** [직선화]·[쉬프트] 도구가 그래프 클릭을 먼저 먹는지(먹었으면 기본 선택을 건너뛴다). */ handleToolPick: (chainageM: number | null) => boolean; - /** 가로 폭 배수·세로 표시 표고창(줌 조작구 상태) — 2026-09-04. */ + /** 가로 폭 배수(줌 조작구 상태) — 세로는 자동이라 배율이 없다(2026-09-04). */ zoom: () => ProfileZoomState; + /** + * 세로 자동 맞춤의 Y 창을 넘겨 주고 **실제로 쓸 창**을 돌려받는다. 계획고를 끌어 올리는 + * 동안에는 본체가 직전 창을 붙잡아 돌려준다 — 축이 손 따라 움직이면 조작 감각이 깨진다. + */ + holdElevationRange: ( + next: { min: number; max: number } | null, + ) => { min: number; max: number } | undefined; /** 그래프 x → chainage 역변환이 필요한 도구 판정용 — 클릭 지점의 누가거리. */ toolActive: () => boolean; /** [쉬프트]로 고른 직선 구간 — 그래프에 빨갛게 강조한다(2026-09-03). */ @@ -222,13 +233,26 @@ export function renderProfile(ctx: ProfileRenderContext): void { ...graphData, stations: [...regular, ...injected].sort((a, b) => a.chainage_m - b.chainage_m), }; + // 세로 자동 맞춤 — 지금 화면에 보이는 누가거리 구간만 보고 Y 창을 잡는다(2026-09-04 + // 사용자 확정). 가로 스크롤 위치(`scrollLeft`)와 본문 폭이 곧 보이는 구간이다. + const toChainage = chainageInverter(longitudinal, width, originOffset); + const maxChainageM = maxChainageOf(longitudinal); + const viewFromM = Math.max(0, toChainage(scrollLeft)); + const viewToM = Math.min(maxChainageM, toChainage(scrollLeft + body.clientWidth)); + const elevationRange = ctx.holdElevationRange( + windowElevationRange( + [graphLongitudinal.samples, ...designProfiles.map((profile) => profile.samples)], + viewFromM, + viewToM, + ) ?? null, + ); let yAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null; chartWrap.append( createLongitudinalProfile( graphLongitudinal, selectedStationId, - // 세로 배율 = 표시 표고창 높이의 역수(1 = 표고 전범위). - zoom.y, + // 세로 배율은 1 고정 — 확대·축소 몫은 아래 `elevationRange`(자동 맞춤)가 맡는다. + 1, undefined, ctx.selectStation, stationInterval, @@ -252,8 +276,10 @@ export function renderProfile(ctx: ProfileRenderContext): void { // 올린다. 19px는 라벨-버튼 사이가 너무 벌어져 70% 수준(15px)으로 줄였다 // (2026-08-04 사용자 지시). B06은 편집 버튼이 없어 0 유지. 15, - // 표시 표고창의 중심 이동(창 높이 대비 비율) — ▲▼ 버튼이 옮긴다. - zoom.offsetRatio, + // 창 중심 이동은 쓰지 않는다 — 보이는 구간에 맞춘 Y 창이 이미 가운데다. + 0, + // 보이는 구간의 지반·계획선 범위(위아래 10% 여유는 렌더러가 붙인다). + elevationRange, ), ); // 구조물 우클릭 메뉴 — 측점선 위면 삭제, 빈 자리면 배관 추가. 이동도 여기(측점선 끌기)서 한다. @@ -373,6 +399,9 @@ export function renderProfile(ctx: ProfileRenderContext): void { // 범례·기준 버튼 오버레이(top 34px)가 곡선 위에 떠서 그만큼 상단 여유를 준다 // (2026-08-05 사용자 보고: 버튼과 커브 겹침). padTop: 40, + // 유토곡선 Y 도 종단과 같은 창을 본다 — 전 구간 최대 토량으로 고정하면 확대해도 + // 곡선이 납작하게 눌린다(2026-09-04 사용자 지시). + viewRange: { fromM: viewFromM, toM: viewToM }, }, stationInterval: stationIntervalM ?? 1, widthPx: width, diff --git a/B05_Profile/B05_Profile_UI_Profile_Zoom.ts b/B05_Profile/B05_Profile_UI_Profile_Zoom.ts index b6d13d86..fb29af27 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Zoom.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Zoom.ts @@ -1,38 +1,30 @@ /* ============================================================================= * B05_Profile_UI_Profile_Zoom.ts - * 종단면도 줌·Y레인지 조작구 (2026-09-04 사용자 지시). + * 종단면도 줌 조작구 — 버튼 셋(줌인·줌아웃·초기화), 2026-09-04 사용자 확정. * - * 공사 범위가 넓고 고저차가 크면 종단 그래프가 눌려 읽히지 않는다. 조작은 두 갈래다. + * 공사 범위가 넓으면 종단 그래프가 눌려 읽히지 않는다. 사람이 맞출 것은 **가로 하나**다. * * X (가로) — **폭 배수**다. SVG transform 으로 늘리면 그래프만 커지고 측점 테이블· * 계획고 편집 버튼층·구조물 알약 레인이 어긋난다(넷이 같은 `chainageMapper` * 를 쓴다). 캔버스 폭 자체를 키우고 가로 스크롤로 훑는다. - * Y (세로) — **표시 표고창**이다(제안 A, 2026-09-04 사용자 확정). 창 높이 = 전범위 ÷ 배율, - * 창 중심은 창 높이 대비 비율로 위·아래로 옮긴다. 세로 스크롤이 생기지 않아 - * X축·측점 라벨·편집 버튼이 항상 바닥에 남는다. + * Y (세로) — **프로그램이 자동으로 맞춘다**. 보이는 구간의 지반·계획선 범위에 맞춰 + * 잡으므로(`windowElevationRange`) 세로 배율·창 이동 버튼이 필요 없어졌다. + * 옛 `⇕+`·`⇕−`·`▲`·`▼` 네 버튼은 그래서 없앴다. + * + * **배율 1 = 기본값이자 축소 한계**(사용자 확정) — 폭맞춤보다 더 줄이면 측점이 겹쳐 + * 읽을 수 없다. 한계에 닿은 버튼은 흐리게 죽인다. * * 배율은 페이지가 들고 있다 — 편집·재계산으로 다시 그려도 유지된다(B06 `cardZoomStates` 규칙). - * 버튼 양식은 횡단도 줌 버튼세트(`b06-cross-card__zoom-btn`)와 같고, 설명은 툴팁이다. * ========================================================================== */ /** 한 번 누를 때 배율 배수. 횡단도 줌(1/0.85)보다 성글게 — 폭 배수라 한 칸이 크게 느껴진다. */ const ZOOM_STEP = 1.25; /** 가로 폭 배수 상한 — 이 이상은 캔버스가 수만 px이 되어 브라우저가 버겁다. */ const MAX_X = 8; -/** 세로 배율 상한. 전범위의 1/20 까지 좁혀 본다. */ -const MAX_Y = 20; -/** 창 중심 이동 한 번의 몫 — 창 높이의 10%. */ -const OFFSET_STEP = 0.1; -/** 창 중심 이동 한계 — 전범위 밖으로 완전히 벗어나지 않게 창 높이의 ±2배까지. */ -const MAX_OFFSET = 2; export interface ProfileZoomState { - /** 가로 폭 배수(1 = 현행 폭맞춤). */ + /** 가로 폭 배수(1 = 현행 폭맞춤 = 기본값·축소 한계). */ x: number; - /** 세로 배율(1 = 표고 전범위). */ - y: number; - /** 창 중심 이동 — 창 높이 대비 비율(+ 가 위쪽). */ - offsetRatio: number; } export interface ProfileZoom { @@ -45,12 +37,12 @@ const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value)); export function createProfileZoom(onChange: () => void): ProfileZoom { - const state: ProfileZoomState = { x: 1, y: 1, offsetRatio: 0 }; + const state: ProfileZoomState = { x: 1 }; const bar = document.createElement("div"); bar.className = "b05-profile__zoom"; - function add(label: string, title: string, action: () => void): void { + function add(label: string, title: string, action: () => void): HTMLButtonElement { const button = document.createElement("button"); button.type = "button"; button.className = "b05-profile__zoom-btn"; @@ -60,34 +52,29 @@ export function createProfileZoom(onChange: () => void): ProfileZoom { // 카드·측점 선택으로 번지면 그래프를 다시 그리며 방금 맞춘 배율이 날아간다. event.stopPropagation(); action(); + syncDisabled(); onChange(); }); bar.append(button); + return button; } - add("+", "가로 확대 — 측점 간격을 넓혀 폅니다 (가로 스크롤로 훑음)", () => { + const zoomIn = add("+", "가로 확대 — 측점 간격을 넓혀 폅니다 (세로는 자동으로 맞춥니다)", () => { state.x = clamp(state.x * ZOOM_STEP, 1, MAX_X); }); - add("−", "가로 축소", () => { + const zoomOut = add("−", "가로 축소 — 기본 폭(화면 맞춤)까지만 줄어듭니다", () => { state.x = clamp(state.x / ZOOM_STEP, 1, MAX_X); }); - add("⇕+", "세로 확대 — 표시 표고 폭을 좁혀 고저차를 크게 봅니다", () => { - state.y = clamp(state.y * ZOOM_STEP, 1, MAX_Y); - }); - add("⇕−", "세로 축소", () => { - state.y = clamp(state.y / ZOOM_STEP, 1, MAX_Y); - }); - add("▲", "표시 표고창을 위로 (창 높이의 10%)", () => { - state.offsetRatio = clamp(state.offsetRatio + OFFSET_STEP, -MAX_OFFSET, MAX_OFFSET); - }); - add("▼", "표시 표고창을 아래로 (창 높이의 10%)", () => { - state.offsetRatio = clamp(state.offsetRatio - OFFSET_STEP, -MAX_OFFSET, MAX_OFFSET); - }); - add("⤢", "가로·세로 배율과 표시 표고창을 처음 상태로", () => { + add("⤢", "기본 상태로 — 가로 폭맞춤, 세로 자동", () => { state.x = 1; - state.y = 1; - state.offsetRatio = 0; }); + /** 한계에 닿은 버튼은 눌러도 변화가 없다 — 흐리게 죽여 그 사실을 보인다. */ + function syncDisabled(): void { + zoomOut.disabled = state.x <= 1 + 1e-9; + zoomIn.disabled = state.x >= MAX_X - 1e-9; + } + syncDisabled(); + return { bar, state: () => ({ ...state }) }; } diff --git a/B05_Profile/B05_Profile_UI_Style_Table.css b/B05_Profile/B05_Profile_UI_Style_Table.css index 85044eca..5b5fec88 100644 --- a/B05_Profile/B05_Profile_UI_Style_Table.css +++ b/B05_Profile/B05_Profile_UI_Style_Table.css @@ -509,7 +509,13 @@ border-left: none; } -.b05-profile__zoom-btn:hover { +.b05-profile__zoom-btn:hover:not(:disabled) { color: var(--color-text); background: var(--color-surface); } + +/* 배율 한계(축소는 폭맞춤, 확대는 8배)에 닿은 버튼 — 눌러도 변화가 없으니 흐리게 죽인다. */ +.b05-profile__zoom-btn:disabled { + opacity: 0.35; + cursor: default; +} diff --git a/B06_Section/B06_Section_UI_Longitudinal.ts b/B06_Section/B06_Section_UI_Longitudinal.ts index 6facaf19..d942ac73 100644 --- a/B06_Section/B06_Section_UI_Longitudinal.ts +++ b/B06_Section/B06_Section_UI_Longitudinal.ts @@ -174,6 +174,12 @@ export function createLongitudinalProfile( * 표고(m)가 아니라 비율이라 호출부가 노선 표고 범위를 몰라도 된다(2026-09-04). */ elevationOffsetRatio = 0, + /** + * **보이는 구간의 표고 범위**(자동 세로 맞춤, 2026-09-04 사용자 확정). 넘기면 전 구간 + * 최저~최고 대신 이 범위로 Y 창을 잡는다 — 가로로 확대했을 때 그 구간의 고저차가 + * 화면 높이를 채운다. 공통 Y 스케일(`yScaleOptions`)이 있으면 그쪽이 우선이다. + */ + elevationRange?: { min: number; max: number }, ): HTMLElement { const samples = data.samples.filter(validElevation); if (samples.length < 2) return emptyView(L("B06_Profile_View_NoLongitudinal")); @@ -198,8 +204,10 @@ export function createLongitudinalProfile( const elevations = samples .map((sample) => sample.elevation_m) .concat(designProfiles.flatMap((profile) => profile.samples.map((s) => s.elevation_m))); - const rawMin = yScaleOptions?.globalMinElevation ?? Math.min(...elevations); - const rawMax = yScaleOptions?.globalMaxElevation ?? Math.max(...elevations); + const rawMin = + yScaleOptions?.globalMinElevation ?? elevationRange?.min ?? Math.min(...elevations); + const rawMax = + yScaleOptions?.globalMaxElevation ?? elevationRange?.max ?? Math.max(...elevations); const elevationMid = (rawMin + rawMax) / 2; const exaggeration = Math.max(verticalExaggeration, 0.1); // 데이터 영역은 축 프레임(LONG_PAD)보다 originOffsetPx만큼 더 좁게 잡아, diff --git a/B06_Section/B06_Section_UI_Section_Common.ts b/B06_Section/B06_Section_UI_Section_Common.ts index ffa54a7a..f745e7b6 100644 --- a/B06_Section/B06_Section_UI_Section_Common.ts +++ b/B06_Section/B06_Section_UI_Section_Common.ts @@ -223,6 +223,47 @@ export function calculateYScale( }; } +/** + * **보이는 구간의 표고 최저·최고**(종단 그래프 세로 자동 맞춤, 2026-09-04 사용자 확정). + * + * 가로로 확대하면 화면에는 노선의 일부만 남는데 Y 축은 전 구간 범위로 잡혀 있어 곡선이 + * 납작하게 눌린다. 보이는 누가거리 구간만 훑어 그 구간의 범위를 돌려준다 — B05 종단과 + * B06 종단이 같은 함수를 쓴다. + * + * 창 밖 **이웃 한 점**까지 함께 본다. 창 경계를 걸친 선분이 창 안에서 위로 솟는데 그 + * 바깥 끝점을 빼면 선이 축 위로 삐져나온다. + */ +export function windowElevationRange( + series: ReadonlyArray>, + fromM: number, + toM: number, +): { min: number; max: number } | undefined { + let min = Infinity; + let max = -Infinity; + for (const list of series) { + let first = -1; + let last = -1; + for (let index = 0; index < list.length; index += 1) { + const chainage = list[index].chainage_m ?? 0; + if (chainage < fromM || chainage > toM) continue; + if (first < 0) first = index; + last = index; + } + if (first < 0) continue; + for ( + let index = Math.max(0, first - 1); + index <= Math.min(list.length - 1, last + 1); + index += 1 + ) { + const elevation = list[index].elevation_m; + if (typeof elevation !== "number" || !Number.isFinite(elevation)) continue; + if (elevation < min) min = elevation; + if (elevation > max) max = elevation; + } + } + return min <= max ? { min, max } : undefined; +} + export function emptyView(message: string): HTMLElement { const empty = document.createElement("div"); empty.className = "b06-section__empty"; diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 2bf11752..8b445399 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -59,7 +59,6 @@ import { unwrapChart, } from "./B06_Section_UI_Section_View_Panel"; import { - calculateYScale, CROSS_GRID_GAP, CROSS_GRID_MIN_WIDTH, CROSS_WIDTH, @@ -69,7 +68,9 @@ import { emptyView, inferStationInterval, L, - type YScaleOptions, + LONG_PAD, + longitudinalMaxChainage, + windowElevationRange, } from "./B06_Section_UI_Section_Common"; export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl }; @@ -142,7 +143,6 @@ export function createSectionView( let lastChartAvailable = 0; let chartFitScheduled = false; // 카드 단위 재빌드에 재사용하는 렌더 컨텍스트 (draw에서 갱신) - let cachedYScale: YScaleOptions | undefined; let cachedStationInterval = 1; let cachedCardWidth = CROSS_WIDTH; // 같은 행 카드는 같은 높이가 되도록 draw에서 측점별 행 높이를 계산해 둔다(단건 갱신도 이 값 재사용). @@ -473,8 +473,24 @@ export function createSectionView( const heights = chartHeights(lastChartAvailable); const minWidth = longitudinalMinimumWidth(detail.longitudinal, cachedStationInterval); const chartWidth = Math.max(renderWidth, minWidth); - // 종단도 높이가 줄면 Y스케일도 그 높이로 다시 잡아야 표고가 잘리지 않는다. - cachedYScale = calculateYScale(detail, heights.long); + // 종단 그래프의 세로는 **보이는 구간에 자동으로 맞춘다**(2026-09-04 사용자 확정, + // B05 와 같은 규칙·같은 함수). 가로 스크롤 위치와 컨테이너 폭이 곧 보이는 구간이다. + const maxChainageM = longitudinalMaxChainage(detail.longitudinal); + const plotWidth = Math.max(1, chartWidth - LONG_PAD.left - LONG_PAD.right); + const toChainage = (px: number): number => ((px - LONG_PAD.left) / plotWidth) * maxChainageM; + const viewFromM = Math.max(0, toChainage(keepScrollLeft)); + const viewToM = Math.min( + maxChainageM, + toChainage(keepScrollLeft + (chartWrap.clientWidth || chartWidth)), + ); + const longElevationRange = windowElevationRange( + [ + detail.longitudinal.samples, + ...(detail.longitudinal.design_profiles ?? []).map((profile) => profile.samples), + ], + viewFromM, + viewToM, + ); // Y축 눈금을 렌더러에서 받아 가로 스크롤 고정 오버레이로 얹는다(2026-08-04 사용자 // 지시 — 테이블 행 이름표처럼 스크롤해도 계속 보이게, B05와 같은 방식). @@ -485,7 +501,8 @@ export function createSectionView( detail.longitudinal, selectedStationId, currentExaggeration, - cachedYScale, + // 공통 Y 스케일(횡단 카드 몫)을 여기 넘기면 종단이 전 구간 축에 묶여 눌린다. + undefined, (stationId) => selectStation(stationId, true), cachedStationInterval, chartWidth, @@ -496,6 +513,11 @@ export function createSectionView( (axis) => { longAxis = axis; }, + undefined, + undefined, + 0, + 0, + longElevationRange, ), ), ]; @@ -517,6 +539,8 @@ export function createSectionView( selectStation: (stationId) => selectStation(stationId, true), toggleSeries, redraw: drawPanel, + // 유토곡선도 종단과 같은 창을 본다(2026-09-04). + viewRange: { fromM: viewFromM, toM: viewToM }, }); if (massHaul.chart) nodes.push(massHaul.chart); chartWrap.replaceChildren(...nodes); @@ -553,6 +577,20 @@ export function createSectionView( } } + /** 가로 스크롤이 이만큼 멈춰 있으면 보이는 구간이 정해진 것으로 보고 세로를 다시 맞춘다. */ + const SCROLL_SETTLE_MS = 160; + let settledScrollLeft = 0; + let scrollSettleTimer = 0; + // 스크롤·팬 하는 내내 축이 출렁이면 어지럽다 — 멈춘 뒤 한 번만 다시 그린다(2026-09-04). + chartWrap.addEventListener("scroll", () => { + window.clearTimeout(scrollSettleTimer); + scrollSettleTimer = window.setTimeout(() => { + if (Math.abs(chartWrap.scrollLeft - settledScrollLeft) < 1) return; + settledScrollLeft = chartWrap.scrollLeft; + drawPanel(); + }, SCROLL_SETTLE_MS); + }); + const draw = (): void => { if (!currentDetail || !Number.isFinite(renderWidth) || renderWidth <= 0) return; const detail = currentDetail; diff --git a/B06_Section/B06_Section_UI_Section_View_MassHaul.ts b/B06_Section/B06_Section_UI_Section_View_MassHaul.ts index cdb9dd5e..0a728daf 100644 --- a/B06_Section/B06_Section_UI_Section_View_MassHaul.ts +++ b/B06_Section/B06_Section_UI_Section_View_MassHaul.ts @@ -56,6 +56,8 @@ export interface MassHaulPanelInput { toggleSeries: (key: string) => void; /** 범례에서 도형 위치를 초기화한 뒤 패널을 다시 그린다. */ redraw: () => void; + /** 화면에 보이는 누가거리 구간(m) — Y 를 이 구간의 누계 토량으로 잡는다(2026-09-04). */ + viewRange?: { fromM: number; toM: number }; } export interface MassHaulPanelResult { @@ -104,6 +106,7 @@ export function buildMassHaulPanel(input: MassHaulPanelInput): MassHaulPanelResu maxChainageM: longitudinalMaxChainage(detail.longitudinal), padLeft: LONG_PAD.left, padRight: LONG_PAD.right, + viewRange: input.viewRange, }, input.selectedStationId, input.stationInterval, diff --git a/common_util/common_util_mass_haul_view.ts b/common_util/common_util_mass_haul_view.ts index f981e629..d06e516f 100644 --- a/common_util/common_util_mass_haul_view.ts +++ b/common_util/common_util_mass_haul_view.ts @@ -43,6 +43,12 @@ export interface MassHaulAxis { axisX?: number; /** 그래프 위 여백(px). 생략하면 기본(10). B05는 범례 오버레이만큼 크게 준다. */ padTop?: number; + /** + * **화면에 보이는 누가거리 구간**(m). 넘기면 Y 범위를 이 구간의 누계 토량으로 잡는다 + * (2026-09-04 사용자 지시 — 종단 그래프의 세로 자동 맞춤과 같은 창). 생략하면 예전처럼 + * 전 구간 기준 ±200㎥ 고정이다. + */ + viewRange?: { fromM: number; toM: number }; } /** 축 눈금이 읽히는 최소 높이. 이보다 낮아지면 그래프가 뭉개진다. */ @@ -128,9 +134,44 @@ const VOLUME_RANGE_BASE_M3 = 200; * Y축 상·하한을 잡는다. 기본 −200~+200㎥ 고정(0선 항상 포함), 곡선이 넘치는 쪽만 * 데이터에 5% 여유를 더해 확장한다. B05·B06이 같은 함수를 쓰므로 두 화면이 함께 고정된다. */ -function volumeRange(series: MassHaulSeries[]): { min: number; max: number } { +function volumeRange( + series: MassHaulSeries[], + viewRange?: { fromM: number; toM: number }, +): { min: number; max: number } { let rawMin = 0; let rawMax = 0; + if (viewRange) { + // 보이는 구간만 훑는다. 창 경계를 걸친 선분이 안에서 솟구치므로 바깥 이웃 한 점도 본다. + let found = false; + for (const entry of series) { + const points = entry.result.points; + let first = -1; + let last = -1; + for (let index = 0; index < points.length; index += 1) { + const chainage = points[index].chainage_m; + if (chainage < viewRange.fromM || chainage > viewRange.toM) continue; + if (first < 0) first = index; + last = index; + } + if (first < 0) continue; + for ( + let index = Math.max(0, first - 1); + index <= Math.min(points.length - 1, last + 1); + index += 1 + ) { + const volume = points[index].cumulative_volume_m3; + if (!Number.isFinite(volume)) continue; + rawMin = found ? Math.min(rawMin, volume) : volume; + rawMax = found ? Math.max(rawMax, volume) : volume; + found = true; + } + } + if (found) { + // 창 안이 거의 평평하면(구간 토량 변화가 없으면) 최소 폭을 줘 선이 축에 붙지 않게 한다. + const padding = Math.max((rawMax - rawMin) * 0.05, 1); + return { min: rawMin - padding, max: rawMax + padding }; + } + } for (const entry of series) { rawMin = Math.min(rawMin, entry.result.min_cumulative_m3); rawMax = Math.max(rawMax, entry.result.max_cumulative_m3); @@ -323,7 +364,7 @@ export function createMassHaulChart( // 기준 버튼이 라디오가 되면서(택1 표시) Y 범위도 **표시 중인 곡선**으로 잡는다 — // 숨은 기준까지 합쳐 잡으면 선택한 그래프가 눌려 보인다. 아무것도 안 켰으면 전체로 폴백. const rangeSource = series.filter((entry) => visibleKeys.has(entry.key)); - const { min, max } = volumeRange(rangeSource.length ? rangeSource : series); + const { min, max } = volumeRange(rangeSource.length ? rangeSource : series, axis.viewRange); const span = Math.max(max - min, 1e-6); const y = (volume: number) => padTop + ((max - volume) / span) * plotHeight;