fix(B06): 선택 반영을 두 카드로 좁히고 그래프 세로 스크롤 제거

- 선택이 바뀔 때마다 draw()로 모든 카드를 다시 만들어 카드별 휠 줌·팬이 통째로 초기화됐다.
  이전·현재 선택 카드 두 장만 교체하고 상단 패널만 다시 그린다. 선택된 카드 안에서 값 칸을
  토글할 때는 원래부터 재렌더가 없다(DOM 클래스만 바뀐다).
- 접기 손잡이에 전역 캐럿(▸)과 삼각형 아이콘(▼)이 겹쳐 보이던 문제: 같은 특정도로 지우려 해
  로드 순서에서 밀렸다. 자식 결합자로 특정도를 올려 지운다.
- 그래프 상자 세로 스크롤 제거: 축소 시 두 그래프를 각자 비율로 줄이다 하한에 걸리면 합이
  배분 몫을 넘었다. 몫을 먼저 잡고 유토곡선 몫을 종단도 하한이 남도록 클램프해 합이 항상
  몫과 같게 했다. 한 축만 auto면 나머지 축도 auto로 올라가므로 overflow-y: hidden도 건다.
- 패널 위 휠은 끝에 닿아도 기본 동작을 넘기지 않는다(아래 횡단도 목록이 대신 스크롤되던 현상).
  범례 줄이 먼저 먹지 않도록 캡처 단계에서 잡는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-02 14:45:36 +09:00
co-authored by Claude Opus 5
parent 24843c7143
commit b4ee022085
2 changed files with 61 additions and 22 deletions
@@ -99,17 +99,24 @@ function chartHeights(panelHeightPx: number): { long: number; mass: number } {
if (!Number.isFinite(panelHeightPx) || panelHeightPx <= 0) {
return { long: LONG_HEIGHT, mass: MASS_HAUL_HEIGHT };
}
if (panelHeightPx >= BASE_PANEL_HEIGHT) {
return {
long: LONG_HEIGHT,
mass: Math.max(MASS_HAUL_MIN_HEIGHT, panelHeightPx - PANEL_CHROME_PX - LONG_HEIGHT),
};
// 두 그래프가 나눠 가질 실제 몫. **합이 이 값을 넘으면 안 된다** — 넘는 만큼 그래프 상자에
// 세로 스크롤이 생긴다(2026-08-02 사용자 지적). 예전에는 축소 시 각자 비율로 줄이면서
// 하한(110/90)에 걸리면 합이 몫을 넘었다.
const available = Math.max(
panelHeightPx - PANEL_CHROME_PX,
MIN_LONG_HEIGHT + MASS_HAUL_MIN_HEIGHT,
);
// 기본 높이 이상이면 종단도는 고정, 늘어난 몫은 유토곡선이 전부 흡수한다.
if (available >= LONG_HEIGHT + MASS_HAUL_HEIGHT) {
return { long: LONG_HEIGHT, mass: available - LONG_HEIGHT };
}
const ratio = panelHeightPx / BASE_PANEL_HEIGHT;
return {
long: Math.max(MIN_LONG_HEIGHT, Math.round(LONG_HEIGHT * ratio)),
mass: Math.max(MASS_HAUL_MIN_HEIGHT, Math.round(MASS_HAUL_HEIGHT * ratio)),
};
const ratio = available / (LONG_HEIGHT + MASS_HAUL_HEIGHT);
// 유토곡선 몫을 먼저 잡되 종단도 하한을 남겨 둔다 — 그래야 두 하한이 동시에 걸려도 합이 넘지 않는다.
const mass = Math.min(
Math.max(MASS_HAUL_MIN_HEIGHT, Math.round(MASS_HAUL_HEIGHT * ratio)),
available - MIN_LONG_HEIGHT,
);
return { long: available - mass, mass };
}
/**
@@ -226,22 +233,23 @@ export function createSectionView(
panel.append(panelResizer.root);
/**
* 패널 위에서 굴린 휠은 페이지가 아니라 **그래프를 좌우로** 민다(2026-08-02 사용자 지시).
* 가로로 더 밀 곳이 없으면 기본 동작(세로 스크롤)을 그대로 넘긴다.
* 패널 **어디에서든** 굴린 휠은 페이지가 아니라 그래프를 좌우로 민다(2026-08-02 사용자 지시).
*
* 끝에 닿았다고 기본 동작을 넘기면 아래 횡단도 목록이 대신 스크롤돼 화면이 튄다 — 패널 위에
* 커서가 있는 동안은 세로 스크롤을 통째로 막는다. 캡처 단계에서 잡는 이유는 패널 안의 다른
* 가로 스크롤 상자(범례 줄)가 먼저 먹어 버리지 않게 하기 위해서다.
*/
panel.addEventListener(
"wheel",
(event) => {
if (event.ctrlKey) return; // 브라우저 확대 제스처는 건드리지 않는다
event.preventDefault();
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();
if (room <= 1 || !delta) return;
chartWrap.scrollLeft = Math.min(Math.max(chartWrap.scrollLeft + delta, 0), room);
},
{ passive: false },
{ passive: false, capture: true },
);
const panelHeight = (): number => {
@@ -273,13 +281,37 @@ export function createSectionView(
document.getElementById(`cross-${stationId}`)?.scrollIntoView({ behavior, block: "nearest" });
};
/** 측점 id로 카드 하나만 새로 만들어 교체한다(행 높이는 draw가 정해 둔 값을 그대로 쓴다). */
const rebuildCard = (stationId: string | null): void => {
if (!stationId || !currentDetail) return;
const section = currentDetail.cross_sections.find(
(candidate) => candidate.station_id === stationId,
);
const existing = section && document.getElementById(`cross-${stationId}`);
if (section && existing) {
existing.replaceWith(buildCrossCard(section, cachedRowHeight.get(stationId)));
}
};
/**
* 선택이 바뀐 흔적만 화면에 반영한다 — **전체 재렌더 금지**.
* `draw()`로 모든 카드를 다시 만들면 사용자가 카드마다 맞춰 둔 휠 줌·팬(viewBox)이 전부
* 초기화된다(2026-08-02 사용자 지적). 선택 표시가 바뀌는 카드는 이전·현재 둘뿐이다.
*/
const applySelection = (previousStationId: string | null): void => {
rebuildCard(previousStationId);
if (selectedStationId !== previousStationId) rebuildCard(selectedStationId);
drawPanel();
};
/** 같은 측점을 다시 고르면 선택을 푼다(2026-08-02 사용자 지시) — 카드·측점선 어느 쪽이든. */
const selectStation = (stationId: string, scroll: boolean): void => {
const previous = selectedStationId;
const deselect = selectedStationId === stationId;
selectedStationId = deselect ? null : stationId;
// 측점이 바뀌면 면적 강조는 따라가지 않는다(다른 측점의 강조를 물려받으면 오해를 부른다).
activeAreaKey = null;
draw();
applySelection(previous);
if (scroll && !deselect) revealCard(stationId, "smooth");
};
@@ -294,18 +326,20 @@ export function createSectionView(
activeAreaKey = key;
return;
}
const previous = selectedStationId;
selectedStationId = stationId;
activeAreaKey = key;
draw();
applySelection(previous);
revealCard(stationId, "smooth");
};
/** 카드 바깥(그리드 빈 자리)을 누르면 선택을 푼다(2026-08-02 사용자 지시). */
const clearSelection = (): void => {
if (!selectedStationId && !activeAreaKey) return;
const previous = selectedStationId;
selectedStationId = null;
activeAreaKey = null;
draw();
applySelection(previous);
};
const buildCrossCard = (section: CrossSection, forcedHeightPx?: number): HTMLElement =>
@@ -115,7 +115,9 @@
border-radius: 0 0 var(--radius-buttons) var(--radius-buttons);
}
.b06-section__panel-toggle::after {
/* 전역 `.ui-collapsible__title::after` 캐럿을 지운다. 같은 특정도로 쓰면 로드 순서에 따라
전역이 이겨 캐럿(▸)과 삼각형 아이콘(▼)이 **둘 다** 보인다 — 자식 결합자로 특정도를 올린다. */
.b06-section__panel > .b06-section__panel-toggle::after {
content: none;
}
@@ -128,6 +130,9 @@
width: 100%;
min-height: 0;
overflow-x: auto;
/* 한 축만 auto로 두면 브라우저가 나머지 축도 auto로 올려 세로 스크롤이 생긴다.
높이는 `chartHeights()`가 정확히 맞추므로 잘릴 것도 없다. */
overflow-y: hidden;
}
.b06-section__chart {