From 685d44ac39624552b406f6be94b82cbb356eb218 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 22:13:03 +0900 Subject: [PATCH] =?UTF-8?q?perf(B05,B06):=20=EC=9C=A0=ED=86=A0=EA=B3=A1?= =?UTF-8?q?=EC=84=A0=C2=B7B06=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=EC=84=B8=EB=A1=9C=20=EC=B0=BD=EC=9D=84=20=EC=8B=A4=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=EC=9C=BC=EB=A1=9C=20=EB=94=B0=EB=9D=BC=EC=98=A4?= =?UTF-8?q?=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지적(2026-09-04) — 「유토곡선도 반영해야지. B06 페이지도 있잖아. 같은 로직일 텐데 왜 반영이 안 된 거야」. 그리는 쪽(종단 렌더러)은 공용이라 이미 바뀌어 있었고, **스크롤을 받아 창을 옮기는 쪽이 화면마다 따로**라 B05 만 연결돼 있던 것. - 세로 창 모듈을 `common_util_chart_ywindow.ts` 로 옮겨 B05·B06 이 함께 씀. 좌측 고정 축은 표식(`b05-profile__yaxis--ywindow`)이 붙은 것만 옮김 — B06 은 한 컨테이너에 종단·유토곡선 축이 함께 살아 남의 축까지 옮기면 안 됨. - 세로 과장(B06 조절값)을 각인·반영 — 예전에는 과장이 1일 때만 대상이었음. - 유토곡선은 변환이 아니라 **곡선만 다시 그림**(스크롤마다). 곡선 위 말풍선·EP 표·측점 점처럼 세로로 늘어나면 안 되는 것이 많아, 그것만 따로 옮기는 값이 다시 그리는 값보다 큼 (유토 SVG 234개 = 종단의 1/3). - B06 상단 패널을 통째로 다시 그리던 스크롤 처리를 걷어내고, 종단은 변환·유토곡선은 제자리 교체로 바꿈. 700줄 제한에 맞춰 종단 조립을 `_View_Chart.ts`(116줄)로, 유토곡선 조립기를 `_View_MassHaul.ts` 로 분리(727 → 718줄). 자체검증(공용 브라우저 임시 탭): ① B05 유토곡선 눈금이 스크롤을 따라 4.3㎥ → −1,348.8㎥ 로 이동(예전에는 안 따라옴) ② B06 종단 눈금 830/840/850m → 824/826/828m, 유토곡선 68.2㎥ → −3,375.1㎥ 로 동시 이동 ③ B06 최대 프레임 **35.9 → 23.5ms**, 33ms 초과 1 → **0건**(중앙값 16.7ms 유지) ④ B05 최대 프레임 27.4ms, 33ms 초과 0건 ⑤ 전체 시험 381 통과·17 건너뜀. Co-Authored-By: Claude Opus 5 --- B05_Profile/B05_Profile_UI_Profile_Panel.ts | 2 +- B05_Profile/B05_Profile_UI_Profile_Render.ts | 31 +++- B06_Section/B06_Section_UI_Longitudinal.ts | 8 +- B06_Section/B06_Section_UI_Section_View.ts | 163 +++++++++--------- .../B06_Section_UI_Section_View_Chart.ts | 116 +++++++++++++ .../B06_Section_UI_Section_View_MassHaul.ts | 75 ++++++++ .../common_util_chart_ywindow.ts | 23 ++- 7 files changed, 320 insertions(+), 98 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Section_View_Chart.ts rename B05_Profile/B05_Profile_UI_Profile_YWindow.ts => common_util/common_util_chart_ywindow.ts (88%) diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index c8ba1d46..76ed148d 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -98,7 +98,7 @@ const CROSS_PREVIEW_DEBOUNCE_MS = 60; * 동작하도록 여기서 다시 내보낸다(2026-09-04). */ import type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel_Types"; import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel"; -import { needsFullRedraw, type ElevationWindowResult } from "./B05_Profile_UI_Profile_YWindow"; +import { needsFullRedraw, type ElevationWindowResult } from "@util/common_util_chart_ywindow"; export type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel_Types"; export function createRouteProfilePanel( diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 53035c34..cc84fe5d 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -28,7 +28,11 @@ import { } from "./B05_Profile_UI_Profile_Alignment"; import { createEditOverlay } from "./B05_Profile_UI_Profile_Edit"; import { createRunHighlight } from "./B05_Profile_UI_Profile_RunHighlight"; -import { applyElevationWindow, type ElevationWindowResult } from "./B05_Profile_UI_Profile_YWindow"; +import { + applyElevationWindow, + Y_AXIS_WINDOW_CLASS, + type ElevationWindowResult, +} from "@util/common_util_chart_ywindow"; import type { StraightRun } from "./B05_Profile_UI_Profile_Straighten"; import type { MinCoverPoint } from "./B05_Profile_UI_Profile_MinCover"; import { buildStickyYAxis, type RouteMassHaulDrawParams } from "./B05_Profile_UI_Profile_MassHaul"; @@ -329,6 +333,8 @@ export function renderProfile(ctx: ProfileRenderContext): void { }); // 세로 창 갱신기 — 가로로 스크롤할 때마다 패널이 이것을 부른다. 도형은 그대로 두고 // 겹 하나의 변환만 갈아 끼우므로 매 프레임 불러도 된다(2026-09-04 사용자 확정). + // 유토곡선 갱신기 — 아래에서 유토곡선을 그린 뒤 채워진다(그리기 순서상 여기서는 아직 없다). + let massHaulUpdater: ((fromM: number, toM: number) => void) | null = null; ctx.setElevationWindowUpdater(() => { const from = Math.max(0, toChainage(body.scrollLeft)); const to = Math.min(maxChainageM, toChainage(body.scrollLeft + body.clientWidth)); @@ -339,13 +345,19 @@ export function renderProfile(ctx: ProfileRenderContext): void { to, ) ?? null, ); + massHaulUpdater?.(from, to); return applyElevationWindow(chartWrap, next); }); // 가로 스크롤에도 고정되는 sticky Y축 오버레이(SVG와 같은 눈금·불투명 배경으로 값 누출 차단). // 앵커(0크기 sticky)는 **첫 자식**이어야 한다 — SVG 뒤에 붙이면 흐름 위치가 차트 // 아래로 밀려 스크롤 시 축이 화면에 안 보인다(2026-08-04 확인, B06과 같은 규칙). - if (yAxis) chartWrap.prepend(buildStickyYAxis(yAxis, chartHeight)); + if (yAxis) { + const axisOverlay = buildStickyYAxis(yAxis, chartHeight); + // 세로 창을 따라 움직이는 축임을 표시한다(유토곡선 축과 구분). + axisOverlay.classList.add(Y_AXIS_WINDOW_CLASS); + chartWrap.prepend(axisOverlay); + } // 쉬프트로 고른 구간 강조 — 편집 버튼층보다 아래에 깔아 버튼을 가리지 않는다. if (alignment) { const highlight = createRunHighlight({ @@ -392,7 +404,7 @@ export function renderProfile(ctx: ProfileRenderContext): void { // 유토곡선 오버레이 — 종단도·테이블 위를 덮는 서브패널(2026-08-04 사용자 확정). // X 매핑(누가거리 최댓값·여백·폭)을 종단 그래프와 똑같이 넘겨야 측점 세로선이 맞물린다. if (alignment && designProfiles[0]) { - massHaul.draw({ + const massHaulParams: RouteMassHaulDrawParams = { stationSource: graphLongitudinal, // 종단 개략 곡선은 편집이 반영된 현재 계획선을, 정식 곡선은 상세 조회가 내려준 // 측점별 횡단 설계(기본 프리뷰 포함)를 입력으로 쓴다 — B06과 같은 재료다. @@ -429,6 +441,17 @@ export function renderProfile(ctx: ProfileRenderContext): void { onClearSelection: () => { if (ctx.selectedStationId() !== null) ctx.selectStation(null); }, - }); + }; + massHaul.draw(massHaulParams); + // 유토곡선도 **같은 창**을 본다(2026-09-04 사용자 확정) — 스크롤할 때마다 곡선만 다시 + // 그린다. 종단처럼 변환으로 옮기지 않는 이유: 곡선 위에 앉는 말풍선·EP 표·측점 점이 + // 세로로 늘어나면 안 되는 것들이라, 그것만 따로 옮기는 값이 곡선 자체를 다시 그리는 + // 값보다 크다(요소 수가 종단의 1/3). + massHaulUpdater = (fromM, toM) => { + massHaul.draw({ + ...massHaulParams, + axis: { ...massHaulParams.axis, viewRange: { fromM, toM } }, + }); + }; } } diff --git a/B06_Section/B06_Section_UI_Longitudinal.ts b/B06_Section/B06_Section_UI_Longitudinal.ts index 5cae3c89..3026c01f 100644 --- a/B06_Section/B06_Section_UI_Longitudinal.ts +++ b/B06_Section/B06_Section_UI_Longitudinal.ts @@ -54,6 +54,8 @@ export const Y_WINDOW_ATTRS = { /** 플롯 상단 y(px)와 높이(px) — 창 밖으로 나간 글자를 감추는 데 쓴다. */ top: "data-yw-top", height: "data-yw-height", + /** 세로 과장(B06 조절값). 새 창의 px 환산에 그대로 곱한다. */ + exaggeration: "data-yw-exag", } as const; /** @@ -290,8 +292,10 @@ export function createLongitudinalProfile( svg.setAttribute(Y_WINDOW_ATTRS.zero, String(LONG_PAD.top + plotHeight / 2)); svg.setAttribute(Y_WINDOW_ATTRS.top, String(LONG_PAD.top)); svg.setAttribute(Y_WINDOW_ATTRS.height, String(plotHeight)); - // 고정 배율(B06 공통 Y스케일)·세로 과장이 걸린 그래프는 창을 옮기지 않는다. - if (!yScaleOptions && exaggeration === 1) svg.setAttribute(Y_WINDOW_ATTRS.auto, "1"); + svg.setAttribute(Y_WINDOW_ATTRS.exaggeration, String(exaggeration)); + // 고정 배율(B06 공통 Y스케일)에 묶인 그래프만 창을 옮기지 않는다 — 세로 과장은 새 창의 + // px 환산에 그대로 곱하면 되므로 대상이다(B06 종단, 2026-09-04). + if (!yScaleOptions) svg.setAttribute(Y_WINDOW_ATTRS.auto, "1"); const stationInterval = configuredStationInterval ?? inferStationInterval(data.stations); // Y축 눈금: 화면에 담긴 표고 범위를 10칸 안팎으로 나누되, 눈금값이 1·2·5 계열의 diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 8b445399..d07d48f6 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -40,11 +40,22 @@ import { } from "./B06_Section_UI_Cross_View"; import { culvertLinkFor as culvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; -import { createLongitudinalProfile, longitudinalMinimumWidth } from "./B06_Section_UI_Longitudinal"; +import { longitudinalMinimumWidth } from "./B06_Section_UI_Longitudinal"; +import { + buildLongitudinalChart, + visibleChainageRange, + visibleElevationRange, +} from "./B06_Section_UI_Section_View_Chart"; import { applyLegendToggle } from "@util/common_util_mass_haul"; import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view"; import { MASS_HAUL_MIN_HEIGHT } from "@util/common_util_mass_haul_view"; -import { buildMassHaulPanel } from "./B06_Section_UI_Section_View_MassHaul"; +import { createMassHaulRenderer } from "./B06_Section_UI_Section_View_MassHaul"; +import { + applyElevationWindow, + needsFullRedraw, + Y_AXIS_WINDOW_CLASS, + type ElevationWindowResult, +} from "@util/common_util_chart_ywindow"; import { BASE_PANEL_HEIGHT, chartHeights, @@ -56,7 +67,6 @@ import { PANEL_COLLAPSED_KEY, PANEL_HEIGHT_KEY, readVisibleSeries, - unwrapChart, } from "./B06_Section_UI_Section_View_Panel"; import { CROSS_GRID_GAP, @@ -68,9 +78,6 @@ import { emptyView, inferStationInterval, L, - LONG_PAD, - longitudinalMaxChainage, - windowElevationRange, } from "./B06_Section_UI_Section_Common"; export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl }; @@ -455,6 +462,11 @@ export function createSectionView( drawPanel(); }; + /** 유토곡선만 새 창으로 다시 그리는 함수 — 그릴 때마다 새로 만든다(붙인 노드는 그 안). */ + let renderMassHaul: ((fromM: number, toM: number) => void) | null = null; + /** 세로 창 갱신기 — 종단은 변환, 유토곡선은 곡선만. */ + let updateChartWindow: (() => ElevationWindowResult | null) | null = null; + /** 상단 패널 내용(종단면도 + 유토곡선 + 요약)만 다시 그린다. */ function drawPanel(): void { if (!currentDetail || !Number.isFinite(renderWidth) || renderWidth <= 0) return; @@ -473,93 +485,69 @@ export function createSectionView( const heights = chartHeights(lastChartAvailable); const minWidth = longitudinalMinimumWidth(detail.longitudinal, cachedStationInterval); const chartWidth = Math.max(renderWidth, minWidth); - // 종단 그래프의 세로는 **보이는 구간에 자동으로 맞춘다**(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와 같은 방식). - let longAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null; - const nodes: Element[] = [ - unwrapChart( - createLongitudinalProfile( - detail.longitudinal, - selectedStationId, - currentExaggeration, - // 공통 Y 스케일(횡단 카드 몫)을 여기 넘기면 종단이 전 구간 축에 묶여 눌린다. - undefined, - (stationId) => selectStation(stationId, true), - cachedStationInterval, - chartWidth, - heights.long, - minWidth, - detail.longitudinal.design_profiles ?? [], - 0, - (axis) => { - longAxis = axis; - }, - undefined, - undefined, - 0, - 0, - longElevationRange, - ), - ), - ]; - - // 유토곡선 몫은 전용 모듈이 만든다(2026-09-03 · 700줄 제한) — 곡선 계산부터 - // 범례·요약줄까지 한 덩어리라 붙이는 자리만 여기서 정한다. - const massHaul = buildMassHaulPanel({ + const chart = buildLongitudinalChart({ detail, - conversion: currentConversion, - haulLimits: currentHaulLimits, - naturalSpoilSlope: currentNaturalSpoilSlope, - visibleSeries, selectedStationId, + exaggeration: currentExaggeration, + selectStation: (stationId) => selectStation(stationId, true), stationInterval: cachedStationInterval, chartWidth, + chartHeight: heights.long, minWidth, - massHeight: heights.mass, - longHeight: heights.long, - selectStation: (stationId) => selectStation(stationId, true), - toggleSeries, - redraw: drawPanel, - // 유토곡선도 종단과 같은 창을 본다(2026-09-04). - viewRange: { fromM: viewFromM, toM: viewToM }, + scrollLeft: keepScrollLeft, + viewportWidth: chartWrap.clientWidth || chartWidth, }); - if (massHaul.chart) nodes.push(massHaul.chart); + const longAxis = chart.axis; + const nodes: Element[] = [chart.node]; + const { viewFromM, viewToM } = chart; + chartWrap.replaceChildren(...nodes); - // 고정 Y축 오버레이 — 0크기 sticky 앵커라 **첫 자식**으로 넣어야 세로 기준이 컨테이너 - // 상단이 된다(SVG 뒤에 넣으면 앵커가 차트 아래로 밀린다). 유토곡선 축은 종단 높이만큼 - // 안쪽(inner)을 내려 자기 그래프 구간만 덮는다 — 불투명 배경이 지나간 눈금 노출을 막는다. - if (massHaul.axis) { - const overlay = buildStickyYAxis(massHaul.axis, heights.mass); - (overlay.firstElementChild as HTMLElement).style.top = `${heights.long}px`; + // 유토곡선 몫은 전용 모듈이 만들어 붙인다(2026-09-03 · 700줄 제한). 가로로 스크롤할 + // 때는 **이 함수만** 다시 불러 곡선의 세로 창을 따라오게 한다 — 종단면도는 건드리지 + // 않는다(2026-09-04: 상단 패널을 통째로 다시 그리면 화면이 한 번 끊긴다). + renderMassHaul = createMassHaulRenderer( + { + detail, + conversion: currentConversion, + haulLimits: currentHaulLimits, + naturalSpoilSlope: currentNaturalSpoilSlope, + visibleSeries, + selectedStationId, + stationInterval: cachedStationInterval, + chartWidth, + minWidth, + massHeight: heights.mass, + longHeight: heights.long, + selectStation: (stationId) => selectStation(stationId, true), + toggleSeries, + redraw: drawPanel, + }, + { chartWrap, panel, panelBody, statusNode: panelCount }, + ); + renderMassHaul(viewFromM, viewToM); + // 종단 고정 Y축 — 0크기 sticky 앵커라 **첫 자식**으로 넣어야 세로 기준이 컨테이너 + // 상단이 된다(SVG 뒤에 넣으면 앵커가 차트 아래로 밀린다). + if (longAxis) { + const overlay = buildStickyYAxis(longAxis, heights.long); + // 세로 창을 따라 움직이는 축임을 표시한다(유토곡선 축과 구분). + overlay.classList.add(Y_AXIS_WINDOW_CLASS); chartWrap.prepend(overlay); } - if (longAxis) chartWrap.prepend(buildStickyYAxis(longAxis, heights.long)); - panel.querySelector(".b06-masshaul__legend")?.remove(); - panel.querySelector(".b06-masshaul__summary")?.remove(); - // 범례는 스크롤 컨테이너 **밖**에 절대 위치로 띄운다 — 안에 넣으면 컨테이너 폭 계산에 - // 끼어들어 두 그래프의 측점 세로선이 어긋난다(1차 수정에서 겪은 문제). - panelBody.append(...massHaul.overlays); - panelCount.textContent = massHaul.statusText; + // 세로 창 갱신기 — 스크롤마다 종단은 변환으로, 유토곡선은 곡선만 다시 그려 따라온다. + updateChartWindow = () => { + const { fromM, toM } = visibleChainageRange( + chart.toChainage, + chart.maxChainageM, + chartWrap.scrollLeft, + chartWrap.clientWidth || chartWidth, + ); + renderMassHaul?.(fromM, toM); + return applyElevationWindow( + chartWrap, + visibleElevationRange(detail, fromM, toM) ?? undefined, + ); + }; chartWrap.scrollLeft = keepScrollLeft; // 상단 패널이 sticky라 선택 카드가 그 아래로 숨는다 — 패널 높이만큼 스크롤 여백을 잡아 준다. syncScrollMargin(); @@ -577,13 +565,16 @@ export function createSectionView( } } - /** 가로 스크롤이 이만큼 멈춰 있으면 보이는 구간이 정해진 것으로 보고 세로를 다시 맞춘다. */ - const SCROLL_SETTLE_MS = 160; + /** 상단 패널을 통째로 다시 그리기까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */ + const SCROLL_SETTLE_MS = 80; let settledScrollLeft = 0; let scrollSettleTimer = 0; - // 스크롤·팬 하는 내내 축이 출렁이면 어지럽다 — 멈춘 뒤 한 번만 다시 그린다(2026-09-04). + // 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정, B05와 같은 규칙) — + // 스크롤마다 겹 하나의 변환만 갈아 끼우므로 실시간으로 따라온다. chartWrap.addEventListener("scroll", () => { + const fitted = updateChartWindow?.() ?? null; window.clearTimeout(scrollSettleTimer); + if (!needsFullRedraw(fitted)) return; scrollSettleTimer = window.setTimeout(() => { if (Math.abs(chartWrap.scrollLeft - settledScrollLeft) < 1) return; settledScrollLeft = chartWrap.scrollLeft; diff --git a/B06_Section/B06_Section_UI_Section_View_Chart.ts b/B06_Section/B06_Section_UI_Section_View_Chart.ts new file mode 100644 index 00000000..468efe3c --- /dev/null +++ b/B06_Section/B06_Section_UI_Section_View_Chart.ts @@ -0,0 +1,116 @@ +/* ============================================================================= + * B06_Section_UI_Section_View_Chart.ts + * B06 상단 패널의 **종단면도 몫**만 떼어 낸 조립기 (2026-09-04 · 700줄 제한). + * + * 세로는 **보이는 구간에 자동으로 맞춘다**(2026-09-04 사용자 확정, B05 와 같은 규칙·같은 + * 함수). 가로 스크롤 위치와 컨테이너 폭이 곧 보이는 구간이다. 스크롤할 때 그래프를 다시 + * 만들지 않고 세로 창을 **변환으로** 옮기는 것도 B05 와 같다 + * (`common_util_chart_ywindow`). + * ========================================================================== */ + +import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; +import { createLongitudinalProfile } from "./B06_Section_UI_Longitudinal"; +import { + LONG_PAD, + longitudinalMaxChainage, + windowElevationRange, +} from "./B06_Section_UI_Section_Common"; +import { unwrapChart } from "./B06_Section_UI_Section_View_Panel"; + +export interface LongitudinalChartInput { + detail: SectionDetailResponse; + selectedStationId: string | null; + /** 세로 과장(사용자 조절값). */ + exaggeration: number; + selectStation: (stationId: string) => void; + stationInterval: number; + chartWidth: number; + chartHeight: number; + minWidth: number; + /** 지금 보이는 구간을 정하는 값 — 가로 스크롤 위치와 컨테이너 안쪽 폭. */ + scrollLeft: number; + viewportWidth: number; +} + +export interface LongitudinalChartResult { + node: Element; + axis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null; + /** 화면 x(px) → 누가거리(m). 스크롤 갱신이 보이는 구간을 다시 잴 때 쓴다. */ + toChainage: (px: number) => number; + maxChainageM: number; + viewFromM: number; + viewToM: number; +} + +/** 지금 보이는 구간(누가거리 m)을 잰다 — 그릴 때와 스크롤 갱신 때가 **같은 식**을 쓴다. */ +export function visibleChainageRange( + toChainage: (px: number) => number, + maxChainageM: number, + scrollLeft: number, + viewportWidth: number, +): { fromM: number; toM: number } { + return { + fromM: Math.max(0, toChainage(scrollLeft)), + toM: Math.min(maxChainageM, toChainage(scrollLeft + viewportWidth)), + }; +} + +/** 보이는 구간의 표고 범위 — 종단 세로 창의 기준. */ +export function visibleElevationRange( + detail: SectionDetailResponse, + fromM: number, + toM: number, +): { min: number; max: number } | null { + return ( + windowElevationRange( + [ + detail.longitudinal.samples, + ...(detail.longitudinal.design_profiles ?? []).map((profile) => profile.samples), + ], + fromM, + toM, + ) ?? null + ); +} + +export function buildLongitudinalChart(input: LongitudinalChartInput): LongitudinalChartResult { + const { detail } = input; + const maxChainageM = longitudinalMaxChainage(detail.longitudinal); + const plotWidth = Math.max(1, input.chartWidth - LONG_PAD.left - LONG_PAD.right); + const toChainage = (px: number): number => ((px - LONG_PAD.left) / plotWidth) * maxChainageM; + const { fromM, toM } = visibleChainageRange( + toChainage, + maxChainageM, + input.scrollLeft, + input.viewportWidth, + ); + + // Y축 눈금을 렌더러에서 받아 가로 스크롤 고정 오버레이로 얹는다(2026-08-04 사용자 + // 지시 — 테이블 행 이름표처럼 스크롤해도 계속 보이게, B05와 같은 방식). + let axis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null; + const node = unwrapChart( + createLongitudinalProfile( + detail.longitudinal, + input.selectedStationId, + input.exaggeration, + // 공통 Y 스케일(횡단 카드 몫)을 여기 넘기면 종단이 전 구간 축에 묶여 눌린다. + undefined, + input.selectStation, + input.stationInterval, + input.chartWidth, + input.chartHeight, + input.minWidth, + detail.longitudinal.design_profiles ?? [], + 0, + (next) => { + axis = next; + }, + undefined, + undefined, + 0, + 0, + visibleElevationRange(detail, fromM, toM) ?? undefined, + ), + ); + return { node, axis, toChainage, maxChainageM, viewFromM: fromM, viewToM: toM }; +} diff --git a/B06_Section/B06_Section_UI_Section_View_MassHaul.ts b/B06_Section/B06_Section_UI_Section_View_MassHaul.ts index 0a728daf..d9ffc4c1 100644 --- a/B06_Section/B06_Section_UI_Section_View_MassHaul.ts +++ b/B06_Section/B06_Section_UI_Section_View_MassHaul.ts @@ -12,6 +12,7 @@ * 사용자가 옛 그림을 본다. * ========================================================================== */ +import { buildStickyYAxis } from "../B05_Profile/B05_Profile_UI_Profile_MassHaul"; import type { EarthworkConversion, HaulEquipmentLimit } from "./B06_Section_Api_Fetch"; import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { @@ -142,3 +143,77 @@ export function buildMassHaulPanel(input: MassHaulPanelInput): MassHaulPanelResu : L(series.length ? "B06_MassHaul_AllHidden" : "B06_MassHaul_Empty"); return { chart, axis, overlays, statusText }; } + +/** 유토곡선을 붙일 자리 — 뷰 컨트롤러가 들고 있는 DOM 이다. */ +export interface MassHaulMountTargets { + /** 차트가 들어가는 가로 스크롤 컨테이너. 종단면도와 **같은 부모의 형제**여야 한다. */ + chartWrap: HTMLElement; + /** 옛 범례·요약줄을 찾아 지울 뿌리. */ + panel: HTMLElement; + /** 새 범례·요약줄을 붙일 자리(스크롤 컨테이너 밖). */ + panelBody: HTMLElement; + /** 곡선을 못 그린 이유를 적는 상태줄. */ + statusNode: HTMLElement; +} + +/** 붙여 둔 유토곡선 노드 — 다음 갱신에서 **제자리 교체**하는 데 쓴다. */ +export interface MountedMassHaul { + chart: Element | null; + axis: HTMLElement | null; +} + +/** + * 유토곡선을 만들어 붙인다. 이미 붙어 있으면 **그 자리에서 갈아 끼운다** — 종단면도는 + * 건드리지 않는다(2026-09-04). 가로로 스크롤할 때마다 곡선의 세로 창이 따라와야 하는데, + * 상단 패널을 통째로 다시 그리면 종단 그래프까지 새로 만들어져 화면이 한 번 끊긴다. + */ +export function mountMassHaulPanel( + input: MassHaulPanelInput, + targets: MassHaulMountTargets, + previous: MountedMassHaul, +): MountedMassHaul { + const built = buildMassHaulPanel(input); + let chart = previous.chart; + if (built.chart) { + if (chart?.isConnected) chart.replaceWith(built.chart); + else targets.chartWrap.append(built.chart); + chart = built.chart; + } else if (chart?.isConnected) { + chart.remove(); + chart = null; + } + + let axis = previous.axis; + if (built.axis) { + // 고정 Y축은 0크기 sticky 앵커라 **첫 자식**이어야 세로 기준이 컨테이너 상단이 된다. + // 안쪽(inner)은 종단 높이만큼 내려 자기 그래프 구간만 덮는다. + const overlay = buildStickyYAxis(built.axis, input.massHeight); + (overlay.firstElementChild as HTMLElement).style.top = `${input.longHeight}px`; + if (axis?.isConnected) axis.replaceWith(overlay); + else targets.chartWrap.prepend(overlay); + axis = overlay; + } else if (axis?.isConnected) { + axis.remove(); + axis = null; + } + + targets.panel.querySelector(".b06-masshaul__legend")?.remove(); + targets.panel.querySelector(".b06-masshaul__summary")?.remove(); + targets.panelBody.append(...built.overlays); + targets.statusNode.textContent = built.statusText; + return { chart, axis }; +} + +/** + * 「보이는 구간만 바꿔 다시 그리는」 함수를 만든다 — 스크롤 갱신이 부를 것이다. + * 붙여 둔 노드는 이 함수가 스스로 들고 있으므로 부르는 쪽은 구간만 넘기면 된다. + */ +export function createMassHaulRenderer( + base: Omit, + targets: MassHaulMountTargets, +): (fromM: number, toM: number) => void { + let mounted: MountedMassHaul = { chart: null, axis: null }; + return (fromM, toM) => { + mounted = mountMassHaulPanel({ ...base, viewRange: { fromM, toM } }, targets, mounted); + }; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_YWindow.ts b/common_util/common_util_chart_ywindow.ts similarity index 88% rename from B05_Profile/B05_Profile_UI_Profile_YWindow.ts rename to common_util/common_util_chart_ywindow.ts index 2f561f58..5a631ea8 100644 --- a/B05_Profile/B05_Profile_UI_Profile_YWindow.ts +++ b/common_util/common_util_chart_ywindow.ts @@ -1,5 +1,5 @@ /* ============================================================================= - * B05_Profile_UI_Profile_YWindow.ts + * common_util_chart_ywindow.ts * 종단 그래프의 **세로 창을 변환으로 갈아 끼운다** — 다시 그리지 않는다(2026-09-04 확정). * * 예전에는 스크롤을 멈출 때마다 그래프를 통째로 새로 만들었다(SVG 요소 457개, 실측 @@ -28,6 +28,9 @@ const TICK_KEEP_MIN = 0.8; const TICK_KEEP_MAX = 1.25; /** 플롯 안에 남아야 하는 최소 눈금 수. */ const TICK_KEEP_MIN_COUNT = 4; +/** 세로 창을 따라 움직이는 좌측 고정 축에 붙이는 표식 — 한 컨테이너에 축이 둘 이상일 때 + * (B06: 종단 + 유토곡선) 남의 축까지 옮기지 않으려는 것이다. */ +export const Y_AXIS_WINDOW_CLASS = "b05-profile__yaxis--ywindow"; /** 눈금 글자는 선보다 이만큼 아래에 앉는다(그리는 쪽과 같은 값). */ const TICK_TEXT_DROP_PX = 4; @@ -38,6 +41,8 @@ interface Baked { zero: number; top: number; height: number; + /** 세로 과장 — 새 창의 px 환산에 그대로 곱한다(B06 조절값, B05 는 1). */ + exaggeration: number; } export interface ElevationWindowResult { @@ -60,6 +65,7 @@ function readBaked(root: ParentNode): Baked | null { zero: read(Y_WINDOW_ATTRS.zero), top: read(Y_WINDOW_ATTRS.top), height: read(Y_WINDOW_ATTRS.height), + exaggeration: read(Y_WINDOW_ATTRS.exaggeration) || 1, }; const numbers = [baked.center, baked.pxPerM, baked.zero, baked.top, baked.height]; if (!numbers.every(Number.isFinite) || baked.pxPerM <= 0 || baked.height <= 0) return null; @@ -81,13 +87,18 @@ function rebuildTicks(baked: Baked, root: ParentNode, center: number, span: numb const lineClass = sampleLine.getAttribute("class") ?? ""; const textX = sampleText.getAttribute("x") ?? "0"; const textClass = sampleText.getAttribute("class") ?? ""; - const axis = root.querySelector(".b05-profile__yaxis-inner"); + const axis = root.querySelector(`.${Y_AXIS_WINDOW_CLASS} .b05-profile__yaxis-inner`); const ns = "http://www.w3.org/2000/svg"; const lines: SVGLineElement[] = []; const texts: SVGTextElement[] = []; const spans: HTMLElement[] = []; - for (const { value, label } of yWindowTickValues(center, span, baked.height)) { + // 눈금값은 **과장을 뺀** 표고 폭 기준이다(그리는 쪽 `rawSpan` 과 같은 값). + for (const { value, label } of yWindowTickValues( + center, + span / baked.exaggeration, + baked.height, + )) { const y = baked.zero - (value - baked.center) * baked.pxPerM; const line = document.createElementNS(ns, "line"); line.setAttribute("x1", x1); @@ -133,7 +144,7 @@ export function applyElevationWindow( // 새 창 — 그리는 쪽과 **같은 규칙**으로 만든다(같은 상수를 같이 쓴다). const span = Math.max(range.max - range.min, 1) * Y_WINDOW_SPAN_PAD; const center = (range.min + range.max) / 2; - const pxPerM = baked.height / span; + const pxPerM = (baked.exaggeration * baked.height) / span; const scale = pxPerM / baked.pxPerM; // 배율이 크게 벌어지면 눈금 간격이 원래 의도와 어긋난다 — 눈금만 새로 만든다(요소 20개 @@ -161,7 +172,9 @@ export function applyElevationWindow( if (inside(moved)) visibleTicks += 1; }); root - .querySelectorAll(`.b05-profile__yaxis-tick[${Y_WINDOW_BAKED_ATTR}]`) + .querySelectorAll( + `.${Y_AXIS_WINDOW_CLASS} .b05-profile__yaxis-tick[${Y_WINDOW_BAKED_ATTR}]`, + ) .forEach((tick) => { const moved = place(Number(tick.getAttribute(Y_WINDOW_BAKED_ATTR))); tick.style.top = `${moved}px`;