/* ============================================================================= * B06_Section_UI_Cross_View_Zoom.ts * 횡단 카드 그래프의 **확대·축소·팬** 조작구. 카드 렌더러(`_UI_Cross_View.ts`)에서 * 700줄 제한으로 분리했다. 동작 규칙(휠은 줌이 아님)은 아래 주석 그대로다. * ========================================================================== */ import { CROSS_PAD, L } from "./B06_Section_UI_Section_Common"; /** * 횡단 카드 줌·팬 제어. 반환한 `zoom()`을 그래프 우측 상단 버튼이 부른다. * * **휠은 줌이 아니다**(2026-08-02 사용자 확정). 카드가 수십 장 깔리는 화면에서 휠을 줌에 * 묶으면 목록을 훑을 수가 없다. 확대·축소는 버튼, 팬은 가운데 버튼 드래그, 원복은 더블클릭. */ export interface ZoomPanHandle { /** 1보다 크면 확대, 작으면 축소. 플롯 영역의 중앙을 붙잡는다. */ zoom: (factor: number) => void; reset: () => void; /** 현재 배율 — 원배율(1)일 때는 버튼이 배율 대신 **표시 반폭**을 조절한다. */ scale: () => number; } /** * 카드 표시 반폭 조작(2026-08-23 사용자 지시). 카드 하단 ◀/▶/↺을 없애고 우측 상단 * 줌 버튼에 합쳤다 — 원배율에서 −는 폭 넓히기, +는 폭 좁히기, ⤢는 교차점 맞춤이다. */ export interface CrossWidthActions { /** 표시 반폭을 deltaM(m)만큼 조절한다. 계산 반폭을 넘으면 페이지가 재생성까지 처리. */ step: (deltaM: number) => void; /** * 초기화 — 표시 반폭을 전역 보기값으로 되돌린다. 단 그 폭에서 절·성토선과 원지반의 * 교차점이 안 보이는 측점은 교차점이 보이는 폭이 그 측점의 기본값이다. */ fit: () => void; } /** 줌·팬 상태 — 카드가 다시 그려질 때 배율을 되살리는 데 쓴다(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; } /** 측점별 줌·팬 상태 — 카드를 다시 만들어도 배율이 살아남는다(2026-08-22 사용자 ③). */ export const cardZoomStates = new Map(); export function attachZoomPan( svg: SVGSVGElement, plotLayer: SVGGElement, widthPx: number, heightPx: number, /** 이전 카드의 줌·팬 상태 — 조정창 조작으로 카드가 다시 그려져도 배율 유지. */ initial?: ZoomPanState, /** 상태가 바뀔 때마다 부른다 — 카드 밖(뷰)이 담아 뒀다가 다음 렌더에 되돌린다. */ onChange?: (state: ZoomPanState) => void, /** 도형이 실제로 그려진 범위(캐시 보유 폭). 확대 상태에서 팬 한계가 여기까지 늘어난다. */ content?: ContentBounds, ): ZoomPanHandle { // 플롯 영역(축 안쪽) — 확대·축소의 중심이자 이동 한계의 기준이다. const plot = { x: CROSS_PAD.left, y: CROSS_PAD.top, width: Math.max(widthPx - CROSS_PAD.left - CROSS_PAD.right, 1), height: Math.max(heightPx - CROSS_PAD.top - CROSS_PAD.bottom, 1), }; const maxScale = 8; let scale = initial?.scale ?? 1; let tx = initial?.tx ?? 0; let ty = initial?.ty ?? 0; const applyTransform = (): void => { plotLayer.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 사용자 재보고: 확대해야만 움직이는 줄 모르고 안 된다고 판단). // 캐시가 표시 폭과 같으면 범위가 플롯과 같아져 종전처럼 이동량 0으로 묶인다. 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 zoom = (factor: number): void => { const next = Math.min(maxScale, Math.max(1, scale * factor)); const cx = plot.x + plot.width / 2; const cy = plot.y + plot.height / 2; tx = cx - ((cx - tx) / scale) * next; ty = cy - ((cy - ty) / scale) * next; scale = next; clampPan(); applyTransform(); }; 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(); }); 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 }; } /** * 그래프 우측 상단 줌 버튼(확대/축소/핏). 휠을 대신하는 조작구다. * * `width`를 넘기면 **원배율에서만** 버튼이 표시 반폭을 조절한다(2026-08-23 사용자 지시): * + : 배율 확대(항상) * − : 배율>1이면 축소, 원배율이면 표시 반폭 +1m(계산 반폭 20m를 넘으면 백엔드 재계산) * ⤢ : 배율 원복 + 표시 반폭 초기화(전역 보기값, 교차점이 안 보이면 보이는 폭까지) */ export function buildZoomControls(handle: ZoomPanHandle, width?: CrossWidthActions): HTMLElement { const bar = document.createElement("div"); bar.className = "b06-cross-card__zoom"; const add = (label: string, title: string, action: () => void): void => { const button = document.createElement("button"); button.type = "button"; button.className = "b06-cross-card__zoom-btn"; button.textContent = label; button.title = title; button.addEventListener("click", (event) => { // 카드 선택으로 번지면 그래프가 다시 그려져 방금 맞춘 배율이 날아간다. event.stopPropagation(); action(); }); bar.append(button); }; // +는 언제나 배율 확대다 — 원배율에서 폭 좁히기로 바꿔 놨더니 배율이 1에서 못 올라가 // 가운데 버튼 팬(배율>1에서만 열림)까지 막혔다(2026-08-23 사용자 보고). add("+", L("B06_Profile_View_ZoomIn"), () => handle.zoom(1 / 0.85)); add("−", L("B06_Profile_View_ZoomOut"), () => { if (!width || handle.scale() > 1 + 1e-6) handle.zoom(0.85); else width.step(1); }); add("⤢", L("B06_Profile_View_ZoomFit"), () => { handle.reset(); width?.fit(); }); return bar; }