/* ============================================================================= * design_profile_viewport.ts * 종단 스크롤러의 가로 스크롤 · 휠 · 크기 바뀜 듣개(213-3 · 700줄 한계로 뗌). * 다시 그리기 · 높이 동기화는 종단도가 넘긴 함수를 부른다. * ========================================================================== */ import { attachWheelHorizontalScroll } from "../../B05_Profile/B05_Profile_UI_Profile_Wheel"; import { needsFullRedraw, type ElevationWindowResult } from "@util/common_util_chart_ywindow"; import { viewOf } from "@util/common_util_view_window"; /** 그래프를 통째로 다시 만들 때까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */ const SCROLL_SETTLE_MS = 80; export interface ProfileViewportDeps { body: HTMLElement; /** ▲▼ 계획고 편집으로 Y 축을 잡고 있는 중. */ editing: () => boolean; /** 세로 창 갱신기 — 그릴 때마다 렌더러가 새로 준다(없으면 null). */ fitElevation: () => ElevationWindowResult | null; draw: () => void; /** 마지막으로 그린 본문 크기 — 같으면 크기 바뀜을 무시한다. */ lastSize: () => { width: number; height: number }; scheduleLightSync: () => void; scheduleDraw: () => void; } /** 듣개를 붙이고 떼는 함수를 돌려준다. */ export function bindProfileViewport(deps: ProfileViewportDeps): () => void { const { body } = deps; /** 마지막으로 통째로 다시 만든 가로 위치 — 같은 자리면 다시 그리지 않는다. */ let settledScrollLeft = 0; let scrollSettleTimer = 0; /** 시계를 건 창 — 새 창으로 빼면 그 창 시계를 쓴다(가려진 메인 창 시계는 늦게 돎 · 230-1). */ let timerView: Window = window; // 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정) — 스크롤마다 겹 하나의 // 변환만 갈아 끼우므로 실시간으로 따라온다. 눈금 간격이 어긋나면 눈금층(요소 20개 안팎)만 // 새로 만든다. 그래프를 통째로 다시 만드는 34ms 짜리 재구성은 그래도 안 될 때만 남는다. body.addEventListener("scroll", () => { if (deps.editing()) return; const fitted = deps.fitElevation(); timerView.clearTimeout(scrollSettleTimer); if (!needsFullRedraw(fitted)) return; timerView = viewOf(body); scrollSettleTimer = timerView.setTimeout(() => { if (deps.editing() || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return; settledScrollLeft = body.scrollLeft; deps.draw(); }, SCROLL_SETTLE_MS); }); // 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도 // 노선을 훑을 수 있게 한다 (Shift+휠은 브라우저 기본 가로 스크롤이라 그대로 둔다). attachWheelHorizontalScroll(body); const resizeObserver = new ResizeObserver(() => { const last = deps.lastSize(); if ( body.clientWidth <= 0 || body.clientHeight <= 0 || (Math.abs(body.clientWidth - last.width) < 1 && Math.abs(body.clientHeight - last.height) < 1) ) return; // 끌리는 동안엔 프레임당 경량 높이 동기화만 — 무거운 전체 재구성은 손을 뗀 뒤 한 번. // (150ms 디바운스만 있던 예전 방식은 중간 프레임이 없어 툭툭 끊겼다. 2026-08-05 사용자 보고) deps.scheduleLightSync(); deps.scheduleDraw(); }); resizeObserver.observe(body); return () => resizeObserver.disconnect(); } /** 고른 것이 보이는 칸 가장자리에서 이만큼(폭 비율) 안쪽이면 그대로 둔다. */ const REVEAL_EDGE = 0.1; /** * 고른 측점선(없으면 고른 구조물 알약)이 종단도 보이는 칸 밖이면 가운데로 민다 — 다른 영역(횡단 카드 · * 3D · 배수유역도 · 좌측 목록)에서 고른 자리도 종단에서 바로 보이게. 칠한 결과(강조된 선 · 알약)를 * 보고 옮긴다 · 같은 고름을 다시 칠할 때는 안 밂(사용자가 옮겨 본 자리를 지킴) · 그래프 안에서 * 누른 것은 이미 보이는 자리라 안 움직임. `key` = 고름 열쇠(풀면 null). */ export function createSelectionReveal(body: HTMLElement): (key: string | null) => void { let shown: string | null = null; return (key) => { if (key === null || key === shown) { shown = key; return; } const mark = body.querySelector(".b06-chart__station--selected") ?? body.querySelector(".b05-structure__mark.is-selected"); if (!mark || body.clientWidth <= 0) return; // 아직 안 그려짐 — 다음 칠하기에서 shown = key; const box = mark.getBoundingClientRect(); const view = body.getBoundingClientRect(); const at = box.left + box.width / 2 - view.left; if (at >= view.width * REVEAL_EDGE && at <= view.width * (1 - REVEAL_EDGE)) return; // 바로 옮김 — 고른 뒤 잇따르는 다시 그리기가 부드러운 이동을 끊음(다시 그리기는 지금 자리를 지킴) body.scrollLeft += at - view.width / 2; }; }