diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts index 2902c8dc..dc66fb10 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts @@ -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", }), diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts index 0c987517..6b7649ac 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts @@ -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[], 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 d1a86c2a..191c1a0b 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts @@ -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 => { 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 9e0e943d..7fdea050 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css @@ -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); } /* 상단 슬라이드 패널(종단면도 + 유토곡선). diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css index d244fb2a..bf64b3fb 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css @@ -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);