perf(B05,B06): 유토곡선·B06 페이지도 세로 창을 실시간으로 따라오게

사용자 지적(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 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 22:13:03 +09:00
co-authored by Claude Opus 5
parent 8dca4bbfb3
commit 685d44ac39
7 changed files with 320 additions and 98 deletions
+1 -1
View File
@@ -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(
+27 -4
View File
@@ -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 } },
});
};
}
}
+6 -2
View File
@@ -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 계열의
+77 -86
View File
@@ -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;
@@ -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 };
}
@@ -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<MassHaulPanelInput, "viewRange">,
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);
};
}
@@ -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<HTMLElement>(".b05-profile__yaxis-inner");
const axis = root.querySelector<HTMLElement>(`.${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<HTMLElement>(`.b05-profile__yaxis-tick[${Y_WINDOW_BAKED_ATTR}]`)
.querySelectorAll<HTMLElement>(
`.${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`;