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:
parent
3f479a93cb
commit
1c4edb0cd8
@ -179,6 +179,7 @@ export function BoothLayoutEditorPage() {
|
|||||||
selectedBoothId={selectedBoothId}
|
selectedBoothId={selectedBoothId}
|
||||||
onSelectBooth={setSelectedBoothId}
|
onSelectBooth={setSelectedBoothId}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
|
onZoomChange={setZoom}
|
||||||
floorplanUrl={hallFloorplanUrl(hallId)}
|
floorplanUrl={hallFloorplanUrl(hallId)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,7 +1,12 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { BoothDto } from '../../api/types';
|
import type { BoothDto } from '../../api/types';
|
||||||
import './canvas.css';
|
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 히어로).
|
* 플로어플랜 SVG 캔버스 (SCR-03 히어로).
|
||||||
* - 다크 서피스(#1C2536) 위 부스 폴리곤 + 트렌치 그리드 + 위반 오버레이.
|
* - 다크 서피스(#1C2536) 위 부스 폴리곤 + 트렌치 그리드 + 위반 오버레이.
|
||||||
@ -24,6 +29,8 @@ interface FloorplanCanvasProps {
|
|||||||
zoom: number;
|
zoom: number;
|
||||||
/** 실측 홀 도면(크롤 JPG) 언더레이 URL — 없거나 로드 실패 시 기존 다크 서피스만. */
|
/** 실측 홀 도면(크롤 JPG) 언더레이 URL — 없거나 로드 실패 시 기존 다크 서피스만. */
|
||||||
floorplanUrl?: string | null;
|
floorplanUrl?: string | null;
|
||||||
|
/** 마우스휠 확대/축소 시 페이지 zoom 상태 동기화(소유자 지시 2026-07-14). 미지정 시 휠 줌 비활성. */
|
||||||
|
onZoomChange?: (zoom: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TRENCH_SPACING_M = 9; // 가정 트렌치 그리드 간격(실측 대체 — R4)
|
const TRENCH_SPACING_M = 9; // 가정 트렌치 그리드 간격(실측 대체 — R4)
|
||||||
@ -48,11 +55,72 @@ export function FloorplanCanvas({
|
|||||||
onSelectBooth,
|
onSelectBooth,
|
||||||
zoom,
|
zoom,
|
||||||
floorplanUrl,
|
floorplanUrl,
|
||||||
|
onZoomChange,
|
||||||
}: FloorplanCanvasProps) {
|
}: FloorplanCanvasProps) {
|
||||||
const [hw, hh] = hallDims;
|
const [hw, hh] = hallDims;
|
||||||
const [drawingOk, setDrawingOk] = useState(true);
|
const [drawingOk, setDrawingOk] = useState(true);
|
||||||
useEffect(() => setDrawingOk(true), [floorplanUrl]);
|
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 trenchLines = useMemo(() => {
|
||||||
const lines: { x1: number; y1: number; x2: number; y2: number }[] = [];
|
const lines: { x1: number; y1: number; x2: number; y2: number }[] = [];
|
||||||
for (let x = TRENCH_SPACING_M; x < hw; x += TRENCH_SPACING_M) {
|
for (let x = TRENCH_SPACING_M; x < hw; x += TRENCH_SPACING_M) {
|
||||||
@ -65,7 +133,16 @@ export function FloorplanCanvas({
|
|||||||
}, [hw, hh]);
|
}, [hw, hh]);
|
||||||
|
|
||||||
return (
|
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
|
<svg
|
||||||
className="kx-canvas__svg"
|
className="kx-canvas__svg"
|
||||||
viewBox={`${-PAD} ${-PAD} ${hw + PAD * 2} ${hh + PAD * 2}`}
|
viewBox={`${-PAD} ${-PAD} ${hw + PAD * 2} ${hh + PAD * 2}`}
|
||||||
|
|||||||
@ -3,6 +3,12 @@
|
|||||||
transition: transform 0.12s ease;
|
transition: transform 0.12s ease;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none; /* 포인터 드래그 이동 — 브라우저 제스처 가로채기 방지 */
|
||||||
|
}
|
||||||
|
.kx-canvas.is-panning {
|
||||||
|
transition: none; /* 드래그 중 지연 제거 */
|
||||||
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
.kx-canvas__svg {
|
.kx-canvas__svg {
|
||||||
width: min(900px, 100%);
|
width: min(900px, 100%);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user