feat(web): MazeNET canvas pan + zoom (0.25×–2.5×)
Wheel-to-zoom anchored at the cursor, ZOOM IN/OUT toolbar buttons, and a live zoom% in the status bar. Pan layer gets transform-origin 0 0 and a scale(zoom) factor; grid pattern tile scales with zoom; edge SVG is overflow:visible so long edges don't clip at high zoom. World-space hit-testing, resize deltas, and palette drops all divide by zoom. Reset View zeroes pan AND zoom.
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import React, { forwardRef, useMemo } from 'react';
|
import React, { forwardRef, useMemo } from 'react';
|
||||||
import { RotateCcw, LayoutGrid } from 'lucide-react';
|
import { RotateCcw, LayoutGrid, ZoomIn, ZoomOut } from 'lucide-react';
|
||||||
import NetBox from './NetBox';
|
import NetBox from './NetBox';
|
||||||
import NodeCard from './NodeCard';
|
import NodeCard from './NodeCard';
|
||||||
import type { Net, MazeNode, Edge } from './types';
|
import type { Net, MazeNode, Edge } from './types';
|
||||||
@@ -14,6 +14,7 @@ interface Props {
|
|||||||
selection: Selection;
|
selection: Selection;
|
||||||
setSelection: (s: Selection) => void;
|
setSelection: (s: Selection) => void;
|
||||||
pan: { x: number; y: number };
|
pan: { x: number; y: number };
|
||||||
|
zoom: number;
|
||||||
dropTargetId: string | null;
|
dropTargetId: string | null;
|
||||||
dragging: boolean;
|
dragging: boolean;
|
||||||
edgeDraw: { fromX: number; fromY: number; toX: number; toY: number; hoverTarget: string | null } | null;
|
edgeDraw: { fromX: number; fromY: number; toX: number; toY: number; hoverTarget: string | null } | null;
|
||||||
@@ -28,6 +29,8 @@ interface Props {
|
|||||||
onCanvasContextMenu?: (e: React.MouseEvent) => void;
|
onCanvasContextMenu?: (e: React.MouseEvent) => void;
|
||||||
onResetView?: () => void;
|
onResetView?: () => void;
|
||||||
onAutoLayout?: () => void;
|
onAutoLayout?: () => void;
|
||||||
|
onZoomIn?: () => void;
|
||||||
|
onZoomOut?: () => void;
|
||||||
sseConnected?: boolean;
|
sseConnected?: boolean;
|
||||||
lastEventAt?: Date | null;
|
lastEventAt?: Date | null;
|
||||||
onSelectService?: (nodeId: string, slug: string) => void;
|
onSelectService?: (nodeId: string, slug: string) => void;
|
||||||
@@ -40,10 +43,10 @@ const NODE_W = 140;
|
|||||||
const NODE_HEAD_H = 22;
|
const NODE_HEAD_H = 22;
|
||||||
|
|
||||||
const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
|
const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
|
||||||
{ nets, nodes, edges, deployed, selection, setSelection, pan, dropTargetId, dragging, edgeDraw,
|
{ nets, nodes, edges, deployed, selection, setSelection, pan, zoom, dropTargetId, dragging, edgeDraw,
|
||||||
onCanvasMouseDown, onNodeMouseDown, onNetMouseDown, onNetResizeMouseDown, onPortMouseDown,
|
onCanvasMouseDown, onNodeMouseDown, onNetMouseDown, onNetResizeMouseDown, onPortMouseDown,
|
||||||
onNodeContextMenu, onNetContextMenu, onEdgeContextMenu, onCanvasContextMenu,
|
onNodeContextMenu, onNetContextMenu, onEdgeContextMenu, onCanvasContextMenu,
|
||||||
onResetView, onAutoLayout, sseConnected, lastEventAt, onSelectService },
|
onResetView, onAutoLayout, onZoomIn, onZoomOut, sseConnected, lastEventAt, onSelectService },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const netById = useMemo(() => new Map(nets.map((n) => [n.id, n])), [nets]);
|
const netById = useMemo(() => new Map(nets.map((n) => [n.id, n])), [nets]);
|
||||||
@@ -86,16 +89,34 @@ const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
|
|||||||
<div className="maze-grid-bg">
|
<div className="maze-grid-bg">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg">
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
<defs>
|
<defs>
|
||||||
<pattern id="maze-grid-pat" x={pan.x} y={pan.y} width="40" height="40" patternUnits="userSpaceOnUse">
|
<pattern
|
||||||
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="var(--grid-line)" strokeWidth="1" />
|
id="maze-grid-pat"
|
||||||
|
x={pan.x}
|
||||||
|
y={pan.y}
|
||||||
|
width={40 * zoom}
|
||||||
|
height={40 * zoom}
|
||||||
|
patternUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d={`M ${40 * zoom} 0 L 0 0 0 ${40 * zoom}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--grid-line)"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
</pattern>
|
</pattern>
|
||||||
</defs>
|
</defs>
|
||||||
<rect width="100%" height="100%" fill="url(#maze-grid-pat)" />
|
<rect width="100%" height="100%" fill="url(#maze-grid-pat)" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="maze-pan-layer" style={{ transform: `translate(${pan.x}px, ${pan.y}px)` }}>
|
<div
|
||||||
<svg className="maze-svg">
|
className="maze-pan-layer"
|
||||||
|
style={{
|
||||||
|
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
||||||
|
transformOrigin: '0 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg className="maze-svg" overflow="visible">
|
||||||
<defs>
|
<defs>
|
||||||
<marker id="arrow-matrix" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto">
|
<marker id="arrow-matrix" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto">
|
||||||
<path d="M0,0 L10,5 L0,10 z" fill="#00ff41" />
|
<path d="M0,0 L10,5 L0,10 z" fill="#00ff41" />
|
||||||
@@ -184,10 +205,10 @@ const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(onResetView || onAutoLayout) && (
|
{(onResetView || onAutoLayout || onZoomIn || onZoomOut) && (
|
||||||
<div className="maze-toolbar">
|
<div className="maze-toolbar">
|
||||||
{onResetView && (
|
{onResetView && (
|
||||||
<button type="button" className="maze-btn ghost small" onClick={onResetView} title="Reset pan to origin">
|
<button type="button" className="maze-btn ghost small" onClick={onResetView} title="Reset pan + zoom">
|
||||||
<RotateCcw size={11} /> RESET VIEW
|
<RotateCcw size={11} /> RESET VIEW
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -196,6 +217,16 @@ const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
|
|||||||
<LayoutGrid size={11} /> AUTO-LAYOUT
|
<LayoutGrid size={11} /> AUTO-LAYOUT
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{onZoomOut && (
|
||||||
|
<button type="button" className="maze-btn ghost small" onClick={onZoomOut} title="Zoom out">
|
||||||
|
<ZoomOut size={11} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onZoomIn && (
|
||||||
|
<button type="button" className="maze-btn ghost small" onClick={onZoomIn} title="Zoom in">
|
||||||
|
<ZoomIn size={11} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -205,6 +236,7 @@ const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
|
|||||||
GRAPH {sseConnected ? 'LIVE' : 'IDLE'}
|
GRAPH {sseConnected ? 'LIVE' : 'IDLE'}
|
||||||
</span>
|
</span>
|
||||||
<span className="status-seg">PAN: {Math.round(pan.x)},{Math.round(pan.y)}</span>
|
<span className="status-seg">PAN: {Math.round(pan.x)},{Math.round(pan.y)}</span>
|
||||||
|
<span className="status-seg">ZOOM: {Math.round(zoom * 100)}%</span>
|
||||||
<span className="status-seg">AS-OF {lastEventAt ? fmtTime(lastEventAt) : '--:--:--'}</span>
|
<span className="status-seg">AS-OF {lastEventAt ? fmtTime(lastEventAt) : '--:--:--'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -583,6 +583,7 @@ const MazeNET: React.FC = () => {
|
|||||||
selection={selection}
|
selection={selection}
|
||||||
setSelection={setSelection}
|
setSelection={setSelection}
|
||||||
pan={interaction.pan}
|
pan={interaction.pan}
|
||||||
|
zoom={interaction.zoom}
|
||||||
dropTargetId={interaction.dropTargetId}
|
dropTargetId={interaction.dropTargetId}
|
||||||
dragging={interaction.dragging}
|
dragging={interaction.dragging}
|
||||||
edgeDraw={interaction.edgeDraw}
|
edgeDraw={interaction.edgeDraw}
|
||||||
@@ -597,6 +598,8 @@ const MazeNET: React.FC = () => {
|
|||||||
onCanvasContextMenu={onCanvasContextMenu}
|
onCanvasContextMenu={onCanvasContextMenu}
|
||||||
onResetView={interaction.resetPan}
|
onResetView={interaction.resetPan}
|
||||||
onAutoLayout={() => pushToast({ text: 'AUTO-LAYOUT COMING SOON', tone: 'violet', icon: 'info' })}
|
onAutoLayout={() => pushToast({ text: 'AUTO-LAYOUT COMING SOON', tone: 'violet', icon: 'info' })}
|
||||||
|
onZoomIn={() => interaction.zoomBy(1.2)}
|
||||||
|
onZoomOut={() => interaction.zoomBy(1 / 1.2)}
|
||||||
sseConnected={streamLive}
|
sseConnected={streamLive}
|
||||||
lastEventAt={lastEventAt}
|
lastEventAt={lastEventAt}
|
||||||
onSelectService={(nodeId, slug) => setSelection({ type: 'service', id: slug, nodeId })}
|
onSelectService={(nodeId, slug) => setSelection({ type: 'service', id: slug, nodeId })}
|
||||||
|
|||||||
@@ -39,8 +39,12 @@ interface EdgeDraw {
|
|||||||
hoverTarget: string | null;
|
hoverTarget: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MIN_ZOOM = 0.25;
|
||||||
|
const MAX_ZOOM = 2.5;
|
||||||
|
|
||||||
export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef, onPaletteDrop, onReparent, onAddEdge }: Args) {
|
export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef, onPaletteDrop, onReparent, onAddEdge }: Args) {
|
||||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||||
|
const [zoom, setZoom] = useState(1);
|
||||||
const [drag, setDrag] = useState<Drag>(null);
|
const [drag, setDrag] = useState<Drag>(null);
|
||||||
const [dropTargetId, setDropTargetId] = useState<string | null>(null);
|
const [dropTargetId, setDropTargetId] = useState<string | null>(null);
|
||||||
const [edgeDraw, setEdgeDraw] = useState<EdgeDraw | null>(null);
|
const [edgeDraw, setEdgeDraw] = useState<EdgeDraw | null>(null);
|
||||||
@@ -58,10 +62,12 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef,
|
|||||||
const netsRef = useRef(nets);
|
const netsRef = useRef(nets);
|
||||||
const nodesRef = useRef(nodes);
|
const nodesRef = useRef(nodes);
|
||||||
const panRef = useRef(pan);
|
const panRef = useRef(pan);
|
||||||
|
const zoomRef = useRef(zoom);
|
||||||
const dragRef = useRef(drag);
|
const dragRef = useRef(drag);
|
||||||
useEffect(() => { netsRef.current = nets; }, [nets]);
|
useEffect(() => { netsRef.current = nets; }, [nets]);
|
||||||
useEffect(() => { nodesRef.current = nodes; }, [nodes]);
|
useEffect(() => { nodesRef.current = nodes; }, [nodes]);
|
||||||
useEffect(() => { panRef.current = pan; }, [pan]);
|
useEffect(() => { panRef.current = pan; }, [pan]);
|
||||||
|
useEffect(() => { zoomRef.current = zoom; }, [zoom]);
|
||||||
useEffect(() => { dragRef.current = drag; }, [drag]);
|
useEffect(() => { dragRef.current = drag; }, [drag]);
|
||||||
|
|
||||||
const canvasOriginRef = useRef(() => {
|
const canvasOriginRef = useRef(() => {
|
||||||
@@ -69,11 +75,12 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef,
|
|||||||
return { x: r?.left ?? 0, y: r?.top ?? 0 };
|
return { x: r?.left ?? 0, y: r?.top ?? 0 };
|
||||||
});
|
});
|
||||||
|
|
||||||
/* World-space coords from a client event (applies pan inverse). */
|
/* World-space coords from a client event (applies pan + zoom inverse). */
|
||||||
const toWorld = useCallback((clientX: number, clientY: number) => {
|
const toWorld = useCallback((clientX: number, clientY: number) => {
|
||||||
const o = canvasOriginRef.current();
|
const o = canvasOriginRef.current();
|
||||||
const p = panRef.current;
|
const p = panRef.current;
|
||||||
return { x: clientX - o.x - p.x, y: clientY - o.y - p.y };
|
const z = zoomRef.current;
|
||||||
|
return { x: (clientX - o.x - p.x) / z, y: (clientY - o.y - p.y) / z };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/* ── Mousedown dispatchers ────────────────────────────── */
|
/* ── Mousedown dispatchers ────────────────────────────── */
|
||||||
@@ -138,8 +145,9 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef,
|
|||||||
if (ed) {
|
if (ed) {
|
||||||
const o = canvasOriginRef.current();
|
const o = canvasOriginRef.current();
|
||||||
const p = panRef.current;
|
const p = panRef.current;
|
||||||
const wx = e.clientX - o.x - p.x;
|
const z = zoomRef.current;
|
||||||
const wy = e.clientY - o.y - p.y;
|
const wx = (e.clientX - o.x - p.x) / z;
|
||||||
|
const wy = (e.clientY - o.y - p.y) / z;
|
||||||
const hover = nodesRef.current.find((n) => {
|
const hover = nodesRef.current.find((n) => {
|
||||||
if (n.id === ed.fromId) return false;
|
if (n.id === ed.fromId) return false;
|
||||||
const parent = netsRef.current.find((nn) => nn.id === n.netId);
|
const parent = netsRef.current.find((nn) => nn.id === n.netId);
|
||||||
@@ -163,7 +171,8 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef,
|
|||||||
const w = (() => {
|
const w = (() => {
|
||||||
const o = canvasOriginRef.current();
|
const o = canvasOriginRef.current();
|
||||||
const p = panRef.current;
|
const p = panRef.current;
|
||||||
return { x: e.clientX - o.x - p.x, y: e.clientY - o.y - p.y };
|
const z = zoomRef.current;
|
||||||
|
return { x: (e.clientX - o.x - p.x) / z, y: (e.clientY - o.y - p.y) / z };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
if (d.type === 'net') {
|
if (d.type === 'net') {
|
||||||
@@ -193,8 +202,9 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (d.type === 'resize') {
|
if (d.type === 'resize') {
|
||||||
const dx = e.clientX - d.startX;
|
const z = zoomRef.current;
|
||||||
const dy = e.clientY - d.startY;
|
const dx = (e.clientX - d.startX) / z;
|
||||||
|
const dy = (e.clientY - d.startY) / z;
|
||||||
setNets((prev) => prev.map((n) => {
|
setNets((prev) => prev.map((n) => {
|
||||||
if (n.id !== d.id) return n;
|
if (n.id !== d.id) return n;
|
||||||
let { x, y, w: width, h: height } = d.start;
|
let { x, y, w: width, h: height } = d.start;
|
||||||
@@ -221,8 +231,9 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef,
|
|||||||
setPaletteDrag(null);
|
setPaletteDrag(null);
|
||||||
const o = canvasOriginRef.current();
|
const o = canvasOriginRef.current();
|
||||||
const p = panRef.current;
|
const p = panRef.current;
|
||||||
const wx = e.clientX - o.x - p.x;
|
const z = zoomRef.current;
|
||||||
const wy = e.clientY - o.y - p.y;
|
const wx = (e.clientX - o.x - p.x) / z;
|
||||||
|
const wy = (e.clientY - o.y - p.y) / z;
|
||||||
const rect = canvasRef.current?.getBoundingClientRect();
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
const inside = rect
|
const inside = rect
|
||||||
? e.clientX >= rect.left && e.clientX <= rect.right
|
? e.clientX >= rect.left && e.clientX <= rect.right
|
||||||
@@ -288,10 +299,50 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef,
|
|||||||
};
|
};
|
||||||
}, [setNets, setNodes, dropTargetId, onPaletteDrop, onReparent, onAddEdge, canvasRef]);
|
}, [setNets, setNodes, dropTargetId, onPaletteDrop, onReparent, onAddEdge, canvasRef]);
|
||||||
|
|
||||||
const resetPan = useCallback(() => setPan({ x: 0, y: 0 }), []);
|
const resetPan = useCallback(() => { setPan({ x: 0, y: 0 }); setZoom(1); }, []);
|
||||||
|
|
||||||
|
/* Wheel zoom anchored at cursor — attached as a native non-passive
|
||||||
|
* listener so preventDefault() actually stops the page from scrolling
|
||||||
|
* while zooming the canvas. */
|
||||||
|
useEffect(() => {
|
||||||
|
const el = canvasRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const o = canvasOriginRef.current();
|
||||||
|
const p = panRef.current;
|
||||||
|
const z = zoomRef.current;
|
||||||
|
const factor = Math.exp(-e.deltaY * 0.0015);
|
||||||
|
const nz = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, z * factor));
|
||||||
|
if (nz === z) return;
|
||||||
|
const mx = e.clientX - o.x;
|
||||||
|
const my = e.clientY - o.y;
|
||||||
|
const wx = (mx - p.x) / z;
|
||||||
|
const wy = (my - p.y) / z;
|
||||||
|
setZoom(nz);
|
||||||
|
setPan({ x: mx - wx * nz, y: my - wy * nz });
|
||||||
|
};
|
||||||
|
el.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
return () => el.removeEventListener('wheel', onWheel);
|
||||||
|
}, [canvasRef]);
|
||||||
|
|
||||||
|
const zoomBy = useCallback((mult: number) => {
|
||||||
|
const z = zoomRef.current;
|
||||||
|
const nz = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, z * mult));
|
||||||
|
if (nz === z) return;
|
||||||
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
|
const cx = (rect?.width ?? 0) / 2;
|
||||||
|
const cy = (rect?.height ?? 0) / 2;
|
||||||
|
const p = panRef.current;
|
||||||
|
const wx = (cx - p.x) / z;
|
||||||
|
const wy = (cy - p.y) / z;
|
||||||
|
setZoom(nz);
|
||||||
|
setPan({ x: cx - wx * nz, y: cy - wy * nz });
|
||||||
|
}, [canvasRef]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pan,
|
pan,
|
||||||
|
zoom,
|
||||||
dropTargetId,
|
dropTargetId,
|
||||||
dragging: drag !== null,
|
dragging: drag !== null,
|
||||||
edgeDraw,
|
edgeDraw,
|
||||||
@@ -303,5 +354,6 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef,
|
|||||||
onNetResizeMouseDown,
|
onNetResizeMouseDown,
|
||||||
onPortMouseDown,
|
onPortMouseDown,
|
||||||
resetPan,
|
resetPan,
|
||||||
|
zoomBy,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user