/* ============================================================================= * 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` 만 적는다. * ========================================================================== */ import { wheelEventIn } from "./input_prefs"; /** 줌·팬 상태 — 다시 그릴 때 배율을 되살리는 데 쓴다(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; /** 처음 · 원복 배율(기본 1) — 플롯 중앙을 붙잡음 · 이보다 작게도 줄일 수 있음(266-1 B06 상세). */ homeScale?: number; /** 처음 · 원복 상태를 통째로(295-2 B06 상세 — 중심점 가운데) · 주면 `homeScale` 대신 · 1 보다 작아도 됨. */ home?: ZoomPanState; /** 상태가 바뀔 때마다 부른다 — 바깥이 담아 뒀다가 다음 그림에 되돌린다. */ onChange?: (state: ZoomPanState) => void; /** 도형이 실제로 그려진 범위. 확대 상태에서 팬 한계가 여기까지 늘어난다. */ content?: ContentBounds; /** 휠을 줌에 묶을지(기본 안 묶음). 묶으면 **커서 아래 지점**을 붙잡고 확대한다. */ wheelZoom?: boolean; /** 팬을 시작할 누름인지 — 안 주면 가운데 버튼만. 고르기가 없는 빈 곳 왼쪽 끌기를 더할 때 쓴다. */ panStart?: (event: PointerEvent) => 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; const homeScale = Math.min(Math.max(options.homeScale ?? 1, 1), maxScale); const home: ZoomPanState = options.home ?? { scale: homeScale, tx: (plot.x + plot.width / 2) * (1 - homeScale), ty: (plot.y + plot.height / 2) * (1 - homeScale), }; /** 축소 한계 — 1배 · 처음 보기가 더 작으면 그만큼. */ const minScale = Math.min(1, home.scale); let scale = initial?.scale ?? home.scale; let tx = initial?.tx ?? home.tx; let ty = initial?.ty ?? home.ty; 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(minScale, 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) => { // 누름마다 풂 — 가운데 단추 팬 뒤엔 click 이 안 와 `moved` 가 남아 왼쪽 누름을 삼켰음(259-1) moved = false; // 팬은 **가운데 버튼**만(기본). 좌클릭은 그 화면의 고르기 몫이다. if (!(options.panStart ? options.panStart(event) : 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 (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; // ☰ [설정] 휠 방향(332 · 기본 당기면 확대) — 그림 면이 손가락을 받으므로 핀치는 트랙패드뿐. zoomAt(wheelEventIn(event) ? WHEEL_IN : WHEEL_OUT, cx, cy); }, { passive: false }, ); } // 손가락 둘 = 벌려 확대 · 같이 끌어 이동(기기대응 3-2) — 한 손가락은 그 화면의 고르기 · 목록 밀기 몫. // 휠 줌 화면은 그림 면이 손가락을 다 받음 · 아니면(B06 카드 목록) 한 손가락 밀기는 브라우저에 둠. svg.setAttribute?.("data-touch-own", "pinch"); // ?. = 노드 시험의 흉내 SVG svg.style?.setProperty("touch-action", options.wheelZoom ? "none" : "pan-x pan-y"); const touches = new Map(); const touchPair = (): { x: number; y: number; dist: number } => { const [a, b] = [...touches.values()]; return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, dist: Math.hypot(a.x - b.x, a.y - b.y) }; }; svg.addEventListener("pointerdown", (event) => { if (event.pointerType === "touch") touches.set(event.pointerId, { x: event.clientX, y: event.clientY }); }); svg.addEventListener("pointermove", (event) => { if (event.pointerType !== "touch" || !touches.has(event.pointerId)) return; const before = touches.size === 2 ? touchPair() : null; touches.set(event.pointerId, { x: event.clientX, y: event.clientY }); if (!before) return; const after = touchPair(); const rect = svg.getBoundingClientRect(); const w = Math.max(rect.width, 1); const h = Math.max(rect.height, 1); tx += ((after.x - before.x) / w) * widthPx; ty += ((after.y - before.y) / h) * heightPx; moved = true; const cx = ((after.x - rect.left) / w) * widthPx; const cy = ((after.y - rect.top) / h) * heightPx; zoomAt(before.dist > 0 && after.dist > 0 ? after.dist / before.dist : 1, cx, cy); }); const liftTouch = (event: PointerEvent): void => void touches.delete(event.pointerId); svg.addEventListener("pointerup", liftTouch); svg.addEventListener("pointercancel", liftTouch); const currentScale = (): number => scale; const reset = (): void => { ({ scale, tx, ty } = home); applyTransform(); }; // 더블클릭 원복. svg.addEventListener("dblclick", (event) => { event.stopPropagation(); reset(); }); return { zoom, reset, scale: currentScale }; }