/* ============================================================================= * b_svg_zoom_pan.ts * SVG 그래프 한 장의 **확대·축소·팬** — 여러 화면이 한 코드를 쓴다. * * B06 횡단 카드(`B06_Section_UI_Cross_View_Zoom.ts`)에 있던 것을 옮겨 온 것이다 * (2026-09-12 사용자 지시 「공용화해줘」). 동작 규칙은 그때 정해진 것을 그대로 지킨다. * * · **팬은 가운데(휠) 버튼 전용** — 왼쪽 버튼은 그 화면의 고르기·조작 몫이다. * · **더블클릭이면 원배율**로 돌아간다. * · **휠은 화면마다 다르다** — 카드가 수십 장 깔리는 B06 은 휠을 줌에 묶지 않는다 * (묶으면 목록을 훑을 수가 없다, 2026-08-02 사용자 확정). 판이 둘뿐인 B05 계획노선 * 횡단은 휠 = 줌이다(2026-09-12 사용자 지시 ③). `wheelZoom` 한 칸으로 가른다. * * 그리는 일은 안 한다 — 넘겨받은 `layer` 에 `transform` 만 적는다. * ========================================================================== */ /** 줌·팬 상태 — 다시 그릴 때 배율을 되살리는 데 쓴다(2026-08-22 사용자 ③). */ export interface ZoomPanState { scale: number; tx: number; ty: number; } /** * 그려 둔 도형이 실제로 차지하는 범위(원배율 SVG 좌표). 표시 범위 밖에 있어 잘려 있는 * **캐시 보유분**까지 포함한다 — 확대 상태의 팬은 이 범위까지 갈 수 있다 * (2026-08-23 사용자 지시). */ export interface ContentBounds { x: number; y: number; width: number; height: number; } export interface ZoomPanHandle { /** 1보다 크면 확대, 작으면 축소. 플롯 영역의 중앙을 붙잡는다. */ zoom: (factor: number) => void; reset: () => void; /** 현재 배율 — 원배율(1)일 때는 부르는 쪽 버튼이 배율 대신 표시 폭을 조절한다. */ scale: () => number; } /** 축 안쪽 여백(px) — 확대·축소의 중심이자 이동 한계의 기준이 된다. */ export interface ZoomPanPad { left: number; right: number; top: number; bottom: number; } export interface SvgZoomPanOptions { svg: SVGSVGElement; /** 밀고 키울 도형 묶음 — 여기에만 `transform` 이 적힌다. */ layer: SVGGElement; /** `viewBox` 의 폭·높이(원배율 SVG 좌표). */ widthPx: number; heightPx: number; pad: ZoomPanPad; /** 배율 상한. 안 주면 8. */ maxScale?: number; /** 이전 상태 — 다시 그려도 배율이 살아남게. */ initial?: ZoomPanState; /** 상태가 바뀔 때마다 부른다 — 바깥이 담아 뒀다가 다음 그림에 되돌린다. */ onChange?: (state: ZoomPanState) => void; /** 도형이 실제로 그려진 범위. 확대 상태에서 팬 한계가 여기까지 늘어난다. */ content?: ContentBounds; /** 휠을 줌에 묶을지(기본 안 묶음). 묶으면 **커서 아래 지점**을 붙잡고 확대한다. */ wheelZoom?: boolean; } /** 휠 한 칸에 얼마나 — 노선 편집 지도와 같은 계수(`_RouteEdit_Input`). */ const WHEEL_IN = 1.15; const WHEEL_OUT = 0.87; export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle { const { svg, layer, widthPx, heightPx, pad, initial, onChange, content } = options; // 플롯 영역(축 안쪽) — 확대·축소의 중심이자 이동 한계의 기준이다. const plot = { x: pad.left, y: pad.top, width: Math.max(widthPx - pad.left - pad.right, 1), height: Math.max(heightPx - pad.top - pad.bottom, 1), }; const maxScale = options.maxScale ?? 8; let scale = initial?.scale ?? 1; let tx = initial?.tx ?? 0; let ty = initial?.ty ?? 0; const applyTransform = (): void => { layer.setAttribute("transform", `translate(${tx} ${ty}) scale(${scale})`); onChange?.({ scale, tx, ty }); }; // 확대한 도형이 플롯 영역을 항상 덮게 이동량을 가둔다 — 원배율에서는 이동량이 0으로 묶인다. // **확대 상태**에서는 한계가 그려진 도형 전체(표시 범위 밖 캐시 보유분 포함)까지 늘어나, // 가운데 버튼 팬으로 잘려 있던 지반·설계선을 끌어다 볼 수 있다(2026-08-23 사용자 지시). const clampAxis = ( value: number, start: number, size: number, from: number, to: number, ): number => Math.min( Math.max(value, Math.min(start + size - scale * to, start - scale * from)), Math.max(start + size - scale * to, start - scale * from), ); const clampPan = (): void => { // 도형 범위를 모르면 플롯 영역 자신이 한계다(기존 규칙). 알면 **원배율에서도** 그 // 범위까지 열어 둔다 — 캐시 보유분이 표시 폭보다 넓으면 1배에서도 끌어다 봐야 한다 // (2026-08-23 사용자 재보고: 확대해야만 움직이는 줄 모르고 안 된다고 판단). const bounds = content ? { x: Math.min(content.x, plot.x), y: Math.min(content.y, plot.y), right: Math.max(content.x + content.width, plot.x + plot.width), bottom: Math.max(content.y + content.height, plot.y + plot.height), } : { x: plot.x, y: plot.y, right: plot.x + plot.width, bottom: plot.y + plot.height }; tx = clampAxis(tx, plot.x, plot.width, bounds.x, bounds.right); ty = clampAxis(ty, plot.y, plot.height, bounds.y, bounds.bottom); }; /** 그 자리를 붙잡고 확대·축소한다 — 버튼은 플롯 중앙을, 휠은 커서 자리를 준다. */ const zoomAt = (factor: number, cx: number, cy: number): void => { const next = Math.min(maxScale, Math.max(1, scale * factor)); tx = cx - ((cx - tx) / scale) * next; ty = cy - ((cy - ty) / scale) * next; scale = next; clampPan(); applyTransform(); }; // 버튼에는 마우스 자리가 없다 — 보이는 **플롯 영역의 중앙**을 붙잡는다. const zoom = (factor: number): void => zoomAt(factor, plot.x + plot.width / 2, plot.y + plot.height / 2); let panning = false; 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) => { if (!panning) return; if (Math.abs(event.clientX - lastX) + Math.abs(event.clientY - lastY) > 2) moved = true; // 도형을 직접 미는 방식이라 커서를 따라간다(viewBox를 밀던 때와 부호가 반대다). const rect = svg.getBoundingClientRect(); tx += ((event.clientX - lastX) / rect.width) * widthPx; ty += ((event.clientY - lastY) / rect.height) * heightPx; lastX = event.clientX; lastY = event.clientY; clampPan(); applyTransform(); }); const endPan = (event: PointerEvent): void => { if (!panning) return; panning = false; svg.classList.remove("is-panning"); try { svg.releasePointerCapture(event.pointerId); } catch { /* 이미 해제됨 */ } }; if (initial && (scale !== 1 || tx !== 0 || ty !== 0)) { clampPan(); applyTransform(); } svg.addEventListener("pointerup", endPan); svg.addEventListener("pointercancel", endPan); // 드래그(팬)로 끝난 클릭은 바깥(카드 고르기 등)으로 전파하지 않는다. svg.addEventListener("click", (event) => { if (moved) event.stopPropagation(); }); if (options.wheelZoom) { svg.addEventListener( "wheel", (event) => { event.preventDefault(); // 커서 아래 지점이 제자리에 남게 **그 자리**를 붙잡는다. const rect = svg.getBoundingClientRect(); const cx = ((event.clientX - rect.left) / Math.max(rect.width, 1)) * widthPx; const cy = ((event.clientY - rect.top) / Math.max(rect.height, 1)) * heightPx; // 노선 편집 지도와 같은 방향 — **당기면 확대**(`deltaY > 0`). zoomAt(event.deltaY > 0 ? WHEEL_IN : WHEEL_OUT, cx, cy); }, { passive: false }, ); } const currentScale = (): number => scale; const reset = (): void => { scale = 1; tx = 0; ty = 0; applyTransform(); }; // 더블클릭 원복. svg.addEventListener("dblclick", (event) => { event.stopPropagation(); reset(); }); return { zoom, reset, scale: currentScale }; }