feat(m2): floorplan canvas wheel zoom + left-drag pan (owner directive 07-14)

- non-passive wheel listener zooms 0.3x~4x, synced to page zoom state via
  new onZoomChange prop (wired in BoothLayoutEditorPage)
- pointer-capture left-drag pans the canvas; 3px threshold and click-capture
  suppression so a drag never mis-fires booth select/deselect
- grab/grabbing cursor, transition off while panning, touch-action none

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-14 22:06:45 +09:00
parent 3f479a93cb
commit 1c4edb0cd8
3 changed files with 86 additions and 2 deletions

View File

@ -179,6 +179,7 @@ export function BoothLayoutEditorPage() {
selectedBoothId={selectedBoothId}
onSelectBooth={setSelectedBoothId}
zoom={zoom}
onZoomChange={setZoom}
floorplanUrl={hallFloorplanUrl(hallId)}
/>
)

View File

@ -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<HTMLDivElement | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
if (dragRef.current) {
try {
(e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId);
} catch {
/* 캡처 미보유 무시 */
}
}
setPanning(false);
// moved 플래그는 클릭 억제 판정(onClickCapture)까지 유지 — 클릭 이벤트가 뒤따라 온다.
};
// 드래그 직후 따라오는 click이 부스 선택/해제를 오발하지 않게 캡처 단계에서 차단.
const onClickCapture = (e: React.MouseEvent<HTMLDivElement>) => {
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 (
<div className="kx-canvas" style={{ transform: `scale(${zoom})` }}>
<div
ref={rootRef}
className={`kx-canvas ${panning ? 'is-panning' : ''}`}
style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})` }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onClickCapture={onClickCapture}
>
<svg
className="kx-canvas__svg"
viewBox={`${-PAD} ${-PAD} ${hw + PAD * 2} ${hh + PAD * 2}`}

View File

@ -3,6 +3,12 @@
transition: transform 0.12s ease;
max-width: 100%;
max-height: 100%;
cursor: grab;
touch-action: none; /* 포인터 드래그 이동 — 브라우저 제스처 가로채기 방지 */
}
.kx-canvas.is-panning {
transition: none; /* 드래그 중 지연 제거 */
cursor: grabbing;
}
.kx-canvas__svg {
width: min(900px, 100%);