diff --git a/src/frontend/src/screens/floorplan/BoothLayoutEditorPage.tsx b/src/frontend/src/screens/floorplan/BoothLayoutEditorPage.tsx index dd7d540..08f4d28 100644 --- a/src/frontend/src/screens/floorplan/BoothLayoutEditorPage.tsx +++ b/src/frontend/src/screens/floorplan/BoothLayoutEditorPage.tsx @@ -179,6 +179,7 @@ export function BoothLayoutEditorPage() { selectedBoothId={selectedBoothId} onSelectBooth={setSelectedBoothId} zoom={zoom} + onZoomChange={setZoom} floorplanUrl={hallFloorplanUrl(hallId)} /> ) diff --git a/src/frontend/src/screens/floorplan/FloorplanCanvas.tsx b/src/frontend/src/screens/floorplan/FloorplanCanvas.tsx index 237fd38..fd2e446 100644 --- a/src/frontend/src/screens/floorplan/FloorplanCanvas.tsx +++ b/src/frontend/src/screens/floorplan/FloorplanCanvas.tsx @@ -1,7 +1,12 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import type { BoothDto } from '../../api/types'; import './canvas.css'; +const ZOOM_MIN = 0.3; +const ZOOM_MAX = 4; +const WHEEL_STEP = 1.1; +const DRAG_THRESHOLD_PX = 3; + /* * 플로어플랜 SVG 캔버스 (SCR-03 히어로). * - 다크 서피스(#1C2536) 위 부스 폴리곤 + 트렌치 그리드 + 위반 오버레이. @@ -24,6 +29,8 @@ interface FloorplanCanvasProps { zoom: number; /** 실측 홀 도면(크롤 JPG) 언더레이 URL — 없거나 로드 실패 시 기존 다크 서피스만. */ floorplanUrl?: string | null; + /** 마우스휠 확대/축소 시 페이지 zoom 상태 동기화(소유자 지시 2026-07-14). 미지정 시 휠 줌 비활성. */ + onZoomChange?: (zoom: number) => void; } const TRENCH_SPACING_M = 9; // 가정 트렌치 그리드 간격(실측 대체 — R4) @@ -48,11 +55,72 @@ export function FloorplanCanvas({ onSelectBooth, zoom, floorplanUrl, + onZoomChange, }: FloorplanCanvasProps) { const [hw, hh] = hallDims; const [drawingOk, setDrawingOk] = useState(true); useEffect(() => setDrawingOk(true), [floorplanUrl]); + // ── 뷰어 조작(소유자 지시 2026-07-14): 마우스휠 확대/축소 + 왼쪽 드래그 이동 ── + const rootRef = useRef(null); + const [pan, setPan] = useState({ x: 0, y: 0 }); + const [panning, setPanning] = useState(false); + const dragRef = useRef<{ startX: number; startY: number; baseX: number; baseY: number; moved: boolean } | null>(null); + const zoomRef = useRef(zoom); + zoomRef.current = zoom; + + // 휠 줌 — 페이지 스크롤 억제를 위해 non-passive 리스너로 직접 부착. + useEffect(() => { + const el = rootRef.current; + if (!el || !onZoomChange) return; + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + const factor = e.deltaY < 0 ? WHEEL_STEP : 1 / WHEEL_STEP; + const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoomRef.current * factor)); + onZoomChange(Math.round(next * 100) / 100); + }; + el.addEventListener('wheel', onWheel, { passive: false }); + return () => el.removeEventListener('wheel', onWheel); + }, [onZoomChange]); + + const onPointerDown = (e: React.PointerEvent) => { + if (e.button !== 0) return; // 왼쪽 버튼만 이동 + dragRef.current = { startX: e.clientX, startY: e.clientY, baseX: pan.x, baseY: pan.y, moved: false }; + (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); + }; + const onPointerMove = (e: React.PointerEvent) => { + const d = dragRef.current; + if (!d) return; + const dx = e.clientX - d.startX; + const dy = e.clientY - d.startY; + if (!d.moved && Math.abs(dx) + Math.abs(dy) > DRAG_THRESHOLD_PX) { + d.moved = true; + setPanning(true); + } + if (d.moved) { + setPan({ x: d.baseX + dx, y: d.baseY + dy }); + } + }; + const endDrag = (e: React.PointerEvent) => { + if (dragRef.current) { + try { + (e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId); + } catch { + /* 캡처 미보유 무시 */ + } + } + setPanning(false); + // moved 플래그는 클릭 억제 판정(onClickCapture)까지 유지 — 클릭 이벤트가 뒤따라 온다. + }; + // 드래그 직후 따라오는 click이 부스 선택/해제를 오발하지 않게 캡처 단계에서 차단. + const onClickCapture = (e: React.MouseEvent) => { + if (dragRef.current?.moved) { + e.preventDefault(); + e.stopPropagation(); + } + dragRef.current = null; + }; + const trenchLines = useMemo(() => { const lines: { x1: number; y1: number; x2: number; y2: number }[] = []; for (let x = TRENCH_SPACING_M; x < hw; x += TRENCH_SPACING_M) { @@ -65,7 +133,16 @@ export function FloorplanCanvas({ }, [hw, hh]); return ( -
+