From cbb40dd7cf3aef5ff3a945aa75212fc921daf72d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 2 Aug 2026 14:52:13 +0900 Subject: [PATCH] =?UTF-8?q?fix(B06):=20=EA=B7=B8=EB=9E=98=ED=94=84=20?= =?UTF-8?q?=EB=AA=AB=EC=9D=84=20=EC=8B=A4=EC=B8=A1=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=B1=84=EC=9A=B0=EA=B3=A0=20=EA=B0=80=EC=9A=B4=EB=8D=B0=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EB=93=9C=EB=9E=98=EA=B7=B8=EB=A5=BC=20?= =?UTF-8?q?=ED=8C=AC=EC=9C=BC=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 유토곡선 아래 빈 공간: chartHeights()가 패널높이 − PANEL_CHROME_PX 어림값을 썼는데, 어림값이 실제 chrome보다 크면 그 차이가 그대로 빈 공간이 됐다. chartWrap.clientHeight를 직접 재서 몫으로 쓴다. chartWrap 높이는 내용이 아니라 패널에서 정해지므로 되먹임 없이 수렴하며, 화면에 붙기 전이라 잴 수 없는 첫 렌더만 어림값으로 출발한 뒤 다음 프레임에 한 번 보정한다. 컨테이너 최소 크기는 건드리지 않았다. - 팬을 가운데 버튼 전용으로 바꾸고(좌클릭은 선택) 상단 그래프에도 가운데 버튼 팬을 달았다. 가운데 버튼을 누를 때 브라우저 자동 스크롤이 같이 뜨던 문제는 mousedown/auxclick에서 막는다 (자동 스크롤은 mousedown 기본동작이라 pointerdown에서는 막히지 않는다). Co-Authored-By: Claude Opus 5 (1M context) --- .../B06_wf3_ProfileCross_UI_Cross_View.ts | 14 +++ .../B06_wf3_ProfileCross_UI_Section_View.ts | 86 ++++++++++++++++--- .../B06_wf3_ProfileCross_UI_Style_Cross.css | 5 +- 3 files changed, 90 insertions(+), 15 deletions(-) diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts index ffbf58b1..d0b9292b 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts @@ -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 { diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts index 10e9f558..bd1e263a 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts @@ -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 { * 기본값 이상으로 키우면 → 종단도는 현재 높이 그대로 두고 늘어난 몫은 유토곡선이 먹는다. * 기본값보다 줄이면 → 두 그래프가 같은 비율로 함께 작아진다. */ -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 => { diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css index 179df812..14fc1a73 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css @@ -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; }