fix(B06): 상단 패널 축 제목·범례 위치 정리와 측점 세로선 정렬, 휠 가로 스크롤

- 종단면도 X축 제목을 그래프 상단으로 올린다. 아래쪽은 측점 라벨 몫으로 비워, 바로 밑에
  붙는 유토곡선과 X축을 공유하는 것처럼 읽히게 한다(B05 종단 패널도 같은 렌더러).
- 유토곡선 범례를 그래프 위 오버레이에서 패널 본문 하단으로 내린다. 개수가 늘어도 높이
  배분이 흔들리지 않게 한 줄 고정 + 가로 스크롤. 유토곡선 X축 제목은 삭제(중복).
- 측점 세로선 어긋남 해소: 유토곡선 SVG만 감싸던 `.b06-masshaul__frame`이 스크롤 컨테이너
  폭 계산에 끼어들어 두 SVG의 렌더 폭이 달랐다. SVG는 preserveAspectRatio 기본값이 meet라
  렌더 폭 차이가 그대로 X 매핑 차이가 된다. 상자를 없애 같은 부모의 형제로 맞췄다.
- 선택 횡단도가 sticky 패널에 가리지 않게 패널 실측 높이를 `--b06-cross-scroll-margin`으로
  넘겨 `scroll-margin-top`에 물리고, 스크롤을 center → nearest로 바꿨다. 패널 리사이즈가
  끝나면 선택 카드를 새 높이 기준으로 다시 끌어올린다.
- 패널 위 휠은 페이지 대신 그래프를 좌우로 민다. 더 밀 곳이 없으면 기본 동작을 넘긴다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-02 13:26:42 +09:00
co-authored by Claude Opus 5
parent 39d7c2daee
commit 7bf2cff68e
5 changed files with 69 additions and 41 deletions
@@ -338,9 +338,11 @@ export function createLongitudinalProfile(
y2: heightPx - LONG_PAD.bottom,
class: "b06-chart__axis",
}),
// X축 제목은 그래프 **상단**에 둔다(2026-08-02 사용자 지시). 아래쪽 여백은 측점 라벨 몫이며,
// B06에서는 종단면도 바로 밑에 유토곡선이 붙어 두 그래프가 X축을 공유하는 것처럼 보여야 한다.
svgText(L("B06_Profile_View_LongitudinalXAxis"), {
x: widthPx / 2,
y: heightPx - 4,
y: 14,
"text-anchor": "middle",
class: "b06-chart__axis-label",
}),
@@ -261,6 +261,7 @@ export function createMassHaulChart(
y2: heightPx - LONG_PAD.bottom,
class: "b06-chart__axis",
}),
// X축 제목은 종단면도 상단 하나로 합쳤다 — 여기서 또 적으면 두 그래프가 별개 축으로 읽힌다.
svgText(L("B06_MassHaul_YAxis"), {
x: 15,
y: heightPx / 2,
@@ -268,21 +269,16 @@ export function createMassHaulChart(
transform: `rotate(-90 15 ${heightPx / 2})`,
class: "b06-chart__axis-label",
}),
svgText(L("B06_MassHaul_XAxis"), {
x: widthPx / 2,
y: heightPx - 4,
"text-anchor": "middle",
class: "b06-chart__axis-label",
}),
);
return svg;
}
/**
* 그래프 상단에 겹쳐 놓는 표시/숨김 버튼 줄.
* 곡선 표시/숨김 버튼 줄.
*
* 가로 스크롤 컨테이너 안이라 CSS에서 `position: sticky; left: 0`으로 왼쪽에 고정하고,
* `height: 0`으로 흐름 높이를 먹지 않게 해 곡선 위로 떠 있게 만든다(오버레이).
* 그래프 **아래**에 둔다(2026-08-02 사용자 지시). 예전에는 그래프 위에 겹치는 오버레이였는데,
* 가로 스크롤 컨테이너 안에 있으면 컨테이너 폭 계산에 끼어들어 종단면도와 X축이 어긋난다.
* 지금은 스크롤 컨테이너 **밖** 패널 본문에 붙어 곡선 폭에 전혀 관여하지 않는다.
*/
export function createMassHaulLegend(
series: MassHaulSeries[],
@@ -60,10 +60,11 @@ import {
export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl };
/**
* 헤더·요약줄·선택측점 물량줄·테두리가 먹는 세로 공간. 패널 높이에서 이만큼 빼야 그래프 몫이 된다.
* (`.b06-section__panel-body`가 `overflow: hidden`이라 이 값이 모자라면 아래 줄이 잘린다.)
* 헤더·범례줄·선택측점 물량줄·요약줄·테두리가 먹는 세로 공간. 패널 높이에서 이만큼 빼야
* 그래프 몫이 된다. (`.b06-section__panel-body`가 `overflow: hidden`이라 모자라면 아래 잘린다.
* 범례는 CSS에서 한 줄 고정 가로 스크롤이라 개수가 늘어도 이 값이 흔들리지 않는다.)
*/
const PANEL_CHROME_PX = 128;
const PANEL_CHROME_PX = 160;
/** 손대지 않았을 때의 패널 높이 — 이 값이 축소 비례의 기준(H₀)이 된다. */
const BASE_PANEL_HEIGHT = LONG_HEIGHT + MASS_HAUL_HEIGHT + PANEL_CHROME_PX;
const MIN_LONG_HEIGHT = 110;
@@ -197,11 +198,34 @@ export function createSectionView(
onResize: () => {
// 끄는 동안 매 프레임 SVG를 다시 그리면 손이 늦게 따라온다 — 멈춘 뒤 한 번만 그린다.
window.clearTimeout(panelResizeTimer);
panelResizeTimer = window.setTimeout(drawPanel, 120);
panelResizeTimer = window.setTimeout(() => {
drawPanel();
// 패널이 커지면 선택 카드가 그 아래로 들어간다 — 새 패널 높이에 맞춰 다시 끌어올린다.
revealCard(selectedStationId, "auto");
}, 120);
},
});
panel.append(panelResizer.root);
/**
* 패널 위에서 굴린 휠은 페이지가 아니라 **그래프를 좌우로** 민다(2026-08-02 사용자 지시).
* 가로로 더 밀 곳이 없으면 기본 동작(세로 스크롤)을 그대로 넘긴다.
*/
panel.addEventListener(
"wheel",
(event) => {
if (event.ctrlKey) return; // 브라우저 확대 제스처는 건드리지 않는다
const room = chartWrap.scrollWidth - chartWrap.clientWidth;
if (room <= 1) return;
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
if (!delta) return;
const before = chartWrap.scrollLeft;
chartWrap.scrollLeft = Math.min(Math.max(before + delta, 0), room);
if (chartWrap.scrollLeft !== before) event.preventDefault();
},
{ passive: false },
);
const panelHeight = (): number => {
// 첫 렌더 때 panel은 아직 root에 붙기 전(detached)이라 getComputedStyle이 빈 값을 준다.
// 리사이저가 복원해 둔 인라인 변수부터 읽어야 저장된 높이가 첫 화면부터 반영된다.
@@ -221,14 +245,20 @@ export function createSectionView(
return Number.isFinite(width) ? Math.max(0, width) : 0;
};
/**
* 선택 카드를 화면에 남긴다. 상단 패널이 sticky라 `center`로 보내면 패널이 커졌을 때
* 그 아래로 숨는다 — `nearest` + `scroll-margin-top`(패널 높이)이면 가려질 때만,
* 가려지지 않을 만큼만 움직인다.
*/
const revealCard = (stationId: string | null, behavior: ScrollBehavior): void => {
if (!stationId) return;
document.getElementById(`cross-${stationId}`)?.scrollIntoView({ behavior, block: "nearest" });
};
const selectStation = (stationId: string, scroll: boolean): void => {
selectedStationId = stationId;
draw();
if (scroll) {
document
.getElementById(`cross-${stationId}`)
?.scrollIntoView({ behavior: "smooth", block: "center" });
}
if (scroll) revealCard(stationId, "smooth");
};
const buildCrossCard = (section: CrossSection, forcedHeightPx?: number): HTMLElement =>
@@ -290,12 +320,9 @@ export function createSectionView(
? computeMassHaulSeries(detail.longitudinal, detail.cross_sections, currentConversion)
: [];
if (series.length) {
// 범례는 곡선 위에 겹쳐야 해서 SVG와 한 상자에 넣는다(상자 CSS가 오버레이를 만든다).
const frame = document.createElement("div");
frame.className = "b06-masshaul__frame";
frame.style.minWidth = `${minWidth}px`;
frame.append(
createMassHaulLegend(series, visibleSeries, toggleSeries),
// 유토곡선 SVG는 종단면도와 **같은 부모의 형제**여야 한다. 감싸는 상자를 하나라도 끼우면
// 그 상자가 스크롤 컨테이너 폭 계산에 끼어들어 두 그래프의 측점 세로선이 어긋난다.
nodes.push(
createMassHaulChart(
series,
visibleSeries,
@@ -308,13 +335,15 @@ export function createSectionView(
(stationId) => selectStation(stationId, true),
),
);
nodes.push(frame);
}
chartWrap.replaceChildren(...nodes);
panelTitle.textContent = `${L("B06_Profile_View_Longitudinal")} · ${L("B06_MassHaul_Title")}`;
panel.querySelector(".b06-masshaul__legend")?.remove();
panel.querySelector(".b06-masshaul__summary")?.remove();
panel.querySelector(".b06-masshaul__station")?.remove();
// 범례는 그래프 아래, 스크롤 컨테이너 밖에 둔다(곡선 폭에 관여하지 않게).
if (series.length) panelBody.append(createMassHaulLegend(series, visibleSeries, toggleSeries));
// 요약 수치는 켜 둔 곡선 중 첫 번째 것 — 곡선이 여러 개라 어느 것인지 요약 끝에 밝힌다.
const summarySeries = series.find((entry) => visibleSeries.has(entry.key));
if (summarySeries) {
@@ -332,6 +361,9 @@ export function createSectionView(
panelCount.textContent = L(series.length ? "B06_MassHaul_AllHidden" : "B06_MassHaul_Empty");
}
chartWrap.scrollLeft = keepScrollLeft;
// 상단 패널이 sticky라 선택 카드가 그 아래로 숨는다 — 패널 높이만큼 스크롤 여백을 잡아 준다.
const panelPx = panel.getBoundingClientRect().height || panelHeight();
root.style.setProperty("--b06-cross-scroll-margin", `${Math.round(panelPx + 12)}px`);
}
const draw = (): void => {
@@ -19,11 +19,15 @@
}
.b06-section__panel,
/* 상단 패널이 sticky라 카드로 스크롤하면 그 아래로 숨는다. 패널 높이를 JS가
`--b06-cross-scroll-margin`으로 실어 주고, 스크롤 정지 위치를 그만큼 아래로 민다
(패널을 키우거나 줄이면 이 여백도 따라 바뀐다). */
.b06-cross-card {
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface-raised);
scroll-margin-top: var(--b06-cross-scroll-margin, 240px);
}
/* 상단 슬라이드 패널(종단면도 + 유토곡선).
@@ -9,27 +9,21 @@
* 색상은 theme.css 변수만 쓴다. 횡단 기준의 보라는 참고 도면의 자주색에 대응한다.
* ========================================================================== */
.b06-masshaul__frame {
position: relative;
width: 100%;
}
/* 범례는 그래프 위에 겹친다. `height: 0`이라 세로 흐름을 밀지 않아 곡선 위로 떠 있고,
가로 스크롤 컨테이너 안에서도 `sticky; left: 0`이라 옆으로 밀어도 왼쪽에 남는다. */
/* 범례는 그래프 **아래**, 가로 스크롤 컨테이너 밖에 놓인다(곡선 폭 계산에 끼어들면 종단면도와
측점 세로선이 어긋난다). 개수가 늘어도 패널 높이 배분이 흔들리지 않도록 한 줄로 고정하고,
넘치면 줄바꿈 대신 이 줄만 가로로 민다. */
.b06-masshaul__legend {
position: sticky;
left: 0;
z-index: 2;
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--spacing-4) var(--spacing-8);
height: 0;
padding-inline-start: var(--spacing-8);
flex-wrap: nowrap;
align-items: center;
gap: var(--spacing-8);
padding: var(--spacing-4) var(--spacing-16);
overflow-x: auto;
}
.b06-masshaul__legend-item {
display: inline-flex;
flex: none;
align-items: center;
gap: var(--spacing-8);
padding: var(--spacing-4) var(--spacing-8);