fix(B06): 그래프 몫을 실측으로 채우고 가운데 버튼 드래그를 팬으로 분리

- 유토곡선 아래 빈 공간: chartHeights()가 패널높이 − PANEL_CHROME_PX 어림값을 썼는데, 어림값이
  실제 chrome보다 크면 그 차이가 그대로 빈 공간이 됐다. chartWrap.clientHeight를 직접 재서
  몫으로 쓴다. chartWrap 높이는 내용이 아니라 패널에서 정해지므로 되먹임 없이 수렴하며, 화면에
  붙기 전이라 잴 수 없는 첫 렌더만 어림값으로 출발한 뒤 다음 프레임에 한 번 보정한다.
  컨테이너 최소 크기는 건드리지 않았다.
- 팬을 가운데 버튼 전용으로 바꾸고(좌클릭은 선택) 상단 그래프에도 가운데 버튼 팬을 달았다.
  가운데 버튼을 누를 때 브라우저 자동 스크롤이 같이 뜨던 문제는 mousedown/auxclick에서 막는다
  (자동 스크롤은 mousedown 기본동작이라 pointerdown에서는 막히지 않는다).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-02 14:52:13 +09:00
co-authored by Claude Opus 5
parent b4ee022085
commit cbb40dd7cf
3 changed files with 90 additions and 15 deletions
@@ -165,11 +165,23 @@ function attachZoomPan(svg: SVGSVGElement, widthPx: number, heightPx: number): v
let moved = false;
let lastX = 0;
let lastY = 0;
// 가운데 버튼을 누르면 브라우저가 자동 스크롤(가운데 클릭 스크롤)을 켠다 — `mousedown`
// 기본동작이라 `pointerdown`에서는 못 막는다. 여기서 막아야 팬만 남는다(2026-08-02 사용자 지시).
svg.addEventListener("mousedown", (event) => {
if (event.button === 1) event.preventDefault();
});
svg.addEventListener("auxclick", (event) => {
if (event.button === 1) event.preventDefault();
});
svg.addEventListener("pointerdown", (event) => {
// 팬은 **가운데 버튼**만. 좌클릭은 측점 선택·면적 강조 몫이다.
if (event.button !== 1) return;
event.preventDefault();
panning = true;
moved = false;
lastX = event.clientX;
lastY = event.clientY;
svg.classList.add("is-panning");
svg.setPointerCapture(event.pointerId);
});
svg.addEventListener("pointermove", (event) => {
@@ -184,7 +196,9 @@ function attachZoomPan(svg: SVGSVGElement, widthPx: number, heightPx: number): v
applyVB();
});
const endPan = (event: PointerEvent): void => {
if (!panning) return;
panning = false;
svg.classList.remove("is-panning");
try {
svg.releasePointerCapture(event.pointerId);
} catch {
@@ -61,9 +61,11 @@ import {
export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl };
/**
* 상태 행·요약줄·하단 접기 손잡이·테두리가 먹는 세로 공간. 패널 높이에서 이만큼 빼야
* 그래프 몫이 된다. (`.b06-section__panel-body`가 `overflow: hidden`이라 모자라면 아래가
* 잘린다. 범례는 그래프 위에 겹치는 오버레이라 흐름 높이를 먹지 않는다.)
* 상태 행·요약줄·하단 접기 손잡이·테두리가 먹는 세로 공간의 **어림값**.
*
* 평소에는 쓰지 않는다 — 그래프 몫은 `chartWrap`을 직접 재서 정한다(어림값이 실제보다 크면
* 유토곡선 아래에 빈 공간이 남는다). 화면에 붙기 전이라 잴 수 없는 첫 렌더에서만 쓰는
* 출발값이고, 최소 패널 높이 계산의 기준이기도 하다.
*/
const PANEL_CHROME_PX = 126;
/** 손대지 않았을 때의 패널 높이 — 이 값이 축소 비례의 기준(H₀)이 된다. */
@@ -95,17 +97,13 @@ function readVisibleSeries(): Set<string> {
* 기본값 이상으로 키우면 → 종단도는 현재 높이 그대로 두고 늘어난 몫은 유토곡선이 먹는다.
* 기본값보다 줄이면 → 두 그래프가 같은 비율로 함께 작아진다.
*/
function chartHeights(panelHeightPx: number): { long: number; mass: number } {
if (!Number.isFinite(panelHeightPx) || panelHeightPx <= 0) {
function chartHeights(availableHeightPx: number): { long: number; mass: number } {
if (!Number.isFinite(availableHeightPx) || availableHeightPx <= 0) {
return { long: LONG_HEIGHT, mass: MASS_HAUL_HEIGHT };
}
// 두 그래프가 나눠 가질 실제 몫. **합이 이 값을 넘으면 안 된다** — 넘는 만큼 그래프 상자에
// 세로 스크롤이 생긴다(2026-08-02 사용자 지적). 예전에는 축소 시 각자 비율로 줄이면서
// 하한(110/90)에 걸리면 합이 몫을 넘었다.
const available = Math.max(
panelHeightPx - PANEL_CHROME_PX,
MIN_LONG_HEIGHT + MASS_HAUL_MIN_HEIGHT,
);
// 두 그래프가 나눠 가질 실제 몫. **합이 이 값과 정확히 같아야** 한다 — 넘으면 세로 스크롤이,
// 모자라면 유토곡선 아래에 빈 공간이 남는다(2026-08-02 사용자 지적).
const available = Math.max(availableHeightPx, MIN_LONG_HEIGHT + MASS_HAUL_MIN_HEIGHT);
// 기본 높이 이상이면 종단도는 고정, 늘어난 몫은 유토곡선이 전부 흡수한다.
if (available >= LONG_HEIGHT + MASS_HAUL_HEIGHT) {
return { long: LONG_HEIGHT, mass: available - LONG_HEIGHT };
@@ -164,6 +162,9 @@ export function createSectionView(
let panelResizeTimer = 0;
// 유토곡선 표시 곡선 집합. 토글해도 Y 스케일은 전체 곡선 기준이라 축이 움직이지 않는다.
const visibleSeries = readVisibleSeries();
// 그래프 상자의 마지막 실측 높이와 보정 예약 플래그(빈 공간·세로 스크롤 둘 다 막는다).
let lastChartAvailable = 0;
let chartFitScheduled = false;
// 카드 단위 재빌드에 재사용하는 렌더 컨텍스트 (draw에서 갱신)
let cachedYScale: YScaleOptions | undefined;
let cachedStationInterval = 1;
@@ -252,6 +253,45 @@ export function createSectionView(
{ passive: false, capture: true },
);
/**
* 가운데 버튼 드래그로 그래프를 좌우로 민다(2026-08-02 사용자 지시). 좌클릭은 측점 선택 몫이다.
* 가운데 버튼을 누르면 브라우저가 자동 스크롤을 켜는데, 그건 `mousedown` 기본동작이라
* `pointerdown`에서는 못 막는다 — `mousedown`/`auxclick`에서 막아야 팬만 남는다.
*/
let panPointerId: number | null = null;
let panLastX = 0;
chartWrap.addEventListener("mousedown", (event) => {
if (event.button === 1) event.preventDefault();
});
chartWrap.addEventListener("auxclick", (event) => {
if (event.button === 1) event.preventDefault();
});
chartWrap.addEventListener("pointerdown", (event) => {
if (event.button !== 1) return;
event.preventDefault();
panPointerId = event.pointerId;
panLastX = event.clientX;
chartWrap.classList.add("is-panning");
chartWrap.setPointerCapture(event.pointerId);
});
chartWrap.addEventListener("pointermove", (event) => {
if (panPointerId !== event.pointerId) return;
chartWrap.scrollLeft -= event.clientX - panLastX;
panLastX = event.clientX;
});
const endChartPan = (event: PointerEvent): void => {
if (panPointerId !== event.pointerId) return;
panPointerId = null;
chartWrap.classList.remove("is-panning");
try {
chartWrap.releasePointerCapture(event.pointerId);
} catch {
/* 이미 해제됨 */
}
};
chartWrap.addEventListener("pointerup", endChartPan);
chartWrap.addEventListener("pointercancel", endChartPan);
const panelHeight = (): number => {
// 첫 렌더 때 panel은 아직 root에 붙기 전(detached)이라 getComputedStyle이 빈 값을 준다.
// 리사이저가 복원해 둔 인라인 변수부터 읽어야 저장된 높이가 첫 화면부터 반영된다.
@@ -376,7 +416,15 @@ export function createSectionView(
// 보던 자리가 맨 앞으로 튀면 못 쓴다. 위치를 잡아 뒀다 되돌린다.
const keepScrollLeft = chartWrap.scrollLeft;
const detail = currentDetail;
const heights = chartHeights(panelHeight());
// 그래프 몫은 **추정하지 않고 잰다**. `chartWrap`은 `flex: 1 / min-height: 0`이라 높이가
// 내용이 아니라 패널에서 정해지므로, 재서 쓰면 되먹임 없이 한 번에 수렴한다.
// 화면에 붙기 전(detached)에는 잴 수 없으니 그때만 `PANEL_CHROME_PX` 추정치로 시작한다.
const measured = chartWrap.clientHeight;
lastChartAvailable =
measured > 0
? measured
: Math.max(panelHeight() - PANEL_CHROME_PX, MIN_LONG_HEIGHT + MASS_HAUL_MIN_HEIGHT);
const heights = chartHeights(lastChartAvailable);
const minWidth = longitudinalMinimumWidth(detail.longitudinal, cachedStationInterval);
const chartWidth = Math.max(renderWidth, minWidth);
// 종단도 높이가 줄면 Y스케일도 그 높이로 다시 잡아야 표고가 잘리지 않는다.
@@ -444,6 +492,18 @@ export function createSectionView(
// 상단 패널이 sticky라 선택 카드가 그 아래로 숨는다 — 패널 높이만큼 스크롤 여백을 잡아 준다.
const panelPx = panel.getBoundingClientRect().height || panelHeight();
root.style.setProperty("--b06-cross-scroll-margin", `${Math.round(panelPx + 12)}px`);
// 첫 렌더(detached)나 패널 높이 변경 직후에는 위에서 잰 값이 아직 옛 레이아웃일 수 있다.
// 다음 프레임에 한 번만 다시 재서 어긋나면 고쳐 그린다. `chartWrap` 높이는 내용과 무관하니
// 되먹임 없이 한 번에 수렴한다.
if (!chartFitScheduled) {
chartFitScheduled = true;
requestAnimationFrame(() => {
chartFitScheduled = false;
const now = chartWrap.clientHeight;
if (now > 0 && Math.abs(now - lastChartAvailable) > 2) drawPanel();
});
}
}
const draw = (): void => {
@@ -152,12 +152,13 @@
position: relative;
}
/* 좌클릭은 선택, 팬은 가운데 버튼 드래그다 — 손 모양 커서는 실제로 미는 동안에만 띄운다. */
.b06-cross-card__chart-wrap .b06-section__chart {
cursor: grab;
touch-action: none;
}
.b06-cross-card__chart-wrap .b06-section__chart:active {
.b06-cross-card__chart-wrap .b06-section__chart.is-panning,
.b06-section__chart-wrap.is-panning {
cursor: grabbing;
}