feat(web/mazenet): interaction layer — pan, drag, resize, reparent

This commit is contained in:
2026-04-20 19:22:25 -04:00
parent b928f5d932
commit 0401cccd1d
5 changed files with 298 additions and 36 deletions

View File

@@ -1,8 +1,9 @@
import React, { useMemo } from 'react'; import React, { forwardRef, useMemo } from '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';
import type { Selection } from './Inspector'; import type { Selection } from './Inspector';
import type { ResizeHandle } from './useMazeInteraction';
interface Props { interface Props {
nets: Net[]; nets: Net[];
@@ -10,13 +11,23 @@ interface Props {
edges: Edge[]; edges: Edge[];
selection: Selection; selection: Selection;
setSelection: (s: Selection) => void; setSelection: (s: Selection) => void;
pan?: { x: number; y: number }; pan: { x: number; y: number };
dropTargetId: string | null;
dragging: boolean;
onCanvasMouseDown: (e: React.MouseEvent) => void;
onNodeMouseDown: (id: string) => (e: React.MouseEvent) => void;
onNetMouseDown: (id: string) => (e: React.MouseEvent) => void;
onNetResizeMouseDown: (id: string, handle: ResizeHandle) => (e: React.MouseEvent) => void;
} }
const NODE_W = 140; const NODE_W = 140;
const NODE_HEAD_H = 22; const NODE_HEAD_H = 22;
const Canvas: React.FC<Props> = ({ nets, nodes, edges, selection, setSelection, pan = { x: 0, y: 0 } }) => { const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
{ nets, nodes, edges, selection, setSelection, pan, dropTargetId, dragging,
onCanvasMouseDown, onNodeMouseDown, onNetMouseDown, onNetResizeMouseDown },
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]);
const absPos = (node: MazeNode) => { const absPos = (node: MazeNode) => {
@@ -24,7 +35,6 @@ const Canvas: React.FC<Props> = ({ nets, nodes, edges, selection, setSelection,
return { x: (net?.x ?? 0) + node.x, y: (net?.y ?? 0) + node.y }; return { x: (net?.x ?? 0) + node.x, y: (net?.y ?? 0) + node.y };
}; };
/* nets touched by any edge */
const activeNetIds = useMemo(() => { const activeNetIds = useMemo(() => {
const nodeNet = new Map(nodes.map((n) => [n.id, n.netId])); const nodeNet = new Map(nodes.map((n) => [n.id, n.netId]));
const ids = new Set<string>(); const ids = new Set<string>();
@@ -41,8 +51,13 @@ const Canvas: React.FC<Props> = ({ nets, nodes, edges, selection, setSelection,
return ( return (
<div <div
ref={ref}
className="maze-canvas-wrap" className="maze-canvas-wrap"
onMouseDown={(e) => { if (e.target === e.currentTarget) setSelection(null); }} onMouseDown={(e) => {
if (e.target === e.currentTarget) setSelection(null);
onCanvasMouseDown(e);
}}
style={{ cursor: dragging ? 'grabbing' : 'grab' }}
> >
<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">
@@ -106,9 +121,11 @@ const Canvas: React.FC<Props> = ({ nets, nodes, edges, selection, setSelection,
key={net.id} key={net.id}
net={net} net={net}
selected={net.id === selNetId} selected={net.id === selNetId}
dropTarget={false} dropTarget={dropTargetId === net.id}
inactive={inactive} inactive={inactive}
onSelect={(id) => setSelection({ type: 'net', id })} onSelect={(id) => setSelection({ type: 'net', id })}
onHeaderMouseDown={onNetMouseDown}
onResizeMouseDown={onNetResizeMouseDown}
/> />
); );
})} })}
@@ -121,7 +138,9 @@ const Canvas: React.FC<Props> = ({ nets, nodes, edges, selection, setSelection,
absX={p.x} absX={p.x}
absY={p.y} absY={p.y}
selected={n.id === selNodeId} selected={n.id === selNodeId}
dragging={dragging && n.id === selNodeId}
onSelect={(id) => setSelection({ type: 'node', id })} onSelect={(id) => setSelection({ type: 'node', id })}
onMouseDown={onNodeMouseDown}
/> />
); );
})} })}
@@ -135,6 +154,6 @@ const Canvas: React.FC<Props> = ({ nets, nodes, edges, selection, setSelection,
</div> </div>
</div> </div>
); );
}; });
export default Canvas; export default Canvas;

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { PanelRightOpen, PanelRightClose, RotateCcw, UploadCloud } from 'lucide-react'; import { PanelRightOpen, PanelRightClose, RotateCcw, UploadCloud } from 'lucide-react';
import './MazeNET.css'; import './MazeNET.css';
@@ -10,6 +10,7 @@ import { DEFAULT_SERVICES, DEMO_NETS, DEMO_NODES, DEMO_EDGES } from './data';
import type { ServiceDef } from './data'; import type { ServiceDef } from './data';
import type { Net, MazeNode, Edge, PendingChange } from './types'; import type { Net, MazeNode, Edge, PendingChange } from './types';
import { useMazeApi } from './useMazeApi'; import { useMazeApi } from './useMazeApi';
import { useMazeInteraction } from './useMazeInteraction';
const MazeNET: React.FC = () => { const MazeNET: React.FC = () => {
const api = useMazeApi(); const api = useMazeApi();
@@ -19,12 +20,18 @@ const MazeNET: React.FC = () => {
const [nets, setNets] = useState<Net[]>(DEMO_NETS); const [nets, setNets] = useState<Net[]>(DEMO_NETS);
const [nodes, setNodes] = useState<MazeNode[]>(DEMO_NODES); const [nodes, setNodes] = useState<MazeNode[]>(DEMO_NODES);
const [edges, setEdges] = useState<Edge[]>(DEMO_EDGES); const [edges, setEdges] = useState<Edge[]>(DEMO_EDGES);
const [pending] = useState<PendingChange[]>([]); const [pending, setPending] = useState<PendingChange[]>([]);
const [selection, setSelection] = useState<Selection>(null); const [selection, setSelection] = useState<Selection>(null);
const [inspectorOpen, setInspectorOpen] = useState(true); const [inspectorOpen, setInspectorOpen] = useState(true);
const [services, setServices] = useState<ServiceDef[]>(DEFAULT_SERVICES); const [services, setServices] = useState<ServiceDef[]>(DEFAULT_SERVICES);
const [loadErr, setLoadErr] = useState<string | null>(null); const [loadErr, setLoadErr] = useState<string | null>(null);
const canvasRef = useRef<HTMLDivElement>(null);
const applyChange = useCallback((pc: PendingChange) => {
setPending((p) => [...p, pc]);
}, []);
const interaction = useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange, canvasRef });
/* Load service catalog from API (fall back to defaults if 401/offline). */ /* Load service catalog from API (fall back to defaults if 401/offline). */
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -58,8 +65,18 @@ const MazeNET: React.FC = () => {
setNets(DEMO_NETS); setNodes(DEMO_NODES); setEdges(DEMO_EDGES); setNets(DEMO_NETS); setNodes(DEMO_NODES); setEdges(DEMO_EDGES);
} }
setSelection(null); setSelection(null);
setPending([]);
interaction.resetPan();
}; };
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setSelection(null);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
return ( return (
<div className="maze-page"> <div className="maze-page">
<div className="maze-page-header"> <div className="maze-page-header">
@@ -102,11 +119,19 @@ const MazeNET: React.FC = () => {
> >
<Palette services={services} /> <Palette services={services} />
<Canvas <Canvas
ref={canvasRef}
nets={nets} nets={nets}
nodes={nodes} nodes={nodes}
edges={edges} edges={edges}
selection={selection} selection={selection}
setSelection={setSelection} setSelection={setSelection}
pan={interaction.pan}
dropTargetId={interaction.dropTargetId}
dragging={interaction.dragging}
onCanvasMouseDown={interaction.onCanvasMouseDown}
onNodeMouseDown={interaction.onNodeMouseDown}
onNetMouseDown={interaction.onNetMouseDown}
onNetResizeMouseDown={interaction.onNetResizeMouseDown}
/> />
{inspectorOpen && ( {inspectorOpen && (
<Inspector <Inspector

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { Globe, GitMerge } from 'lucide-react'; import { Globe, GitMerge } from 'lucide-react';
import type { Net } from './types'; import type { Net } from './types';
import type { ResizeHandle } from './useMazeInteraction';
interface Props { interface Props {
net: Net; net: Net;
@@ -8,10 +9,14 @@ interface Props {
dropTarget: boolean; dropTarget: boolean;
inactive: boolean; inactive: boolean;
onSelect?: (id: string) => void; onSelect?: (id: string) => void;
onHeaderMouseDown?: (id: string) => (e: React.MouseEvent) => void;
onResizeMouseDown?: (id: string, handle: ResizeHandle) => (e: React.MouseEvent) => void;
children?: React.ReactNode; children?: React.ReactNode;
} }
const NetBox: React.FC<Props> = ({ net, selected, dropTarget, inactive, onSelect, children }) => { const NetBox: React.FC<Props> = ({
net, selected, dropTarget, inactive, onSelect, onHeaderMouseDown, onResizeMouseDown, children,
}) => {
const classes = [ const classes = [
'maze-net-box', 'maze-net-box',
net.kind === 'internet' ? 'internet' : '', net.kind === 'internet' ? 'internet' : '',
@@ -23,42 +28,45 @@ const NetBox: React.FC<Props> = ({ net, selected, dropTarget, inactive, onSelect
const Icon = net.kind === 'internet' ? Globe : GitMerge; const Icon = net.kind === 'internet' ? Globe : GitMerge;
const resizable = net.kind !== 'internet'; const resizable = net.kind !== 'internet';
const handleBoxDown = (e: React.MouseEvent) => {
if (e.target !== e.currentTarget) return;
onSelect?.(net.id);
};
const handleHeadDown = (e: React.MouseEvent) => {
onSelect?.(net.id);
onHeaderMouseDown?.(net.id)(e);
};
return ( return (
<div <div
className={classes} className={classes}
style={{ left: net.x, top: net.y, width: net.w, height: net.h }} style={{ left: net.x, top: net.y, width: net.w, height: net.h }}
onMouseDown={(e) => { onMouseDown={handleBoxDown}
if (e.target === e.currentTarget) { e.stopPropagation(); onSelect?.(net.id); }
}}
> >
<div <div className="maze-net-box-head" onMouseDown={handleHeadDown}>
className="maze-net-box-head"
onMouseDown={(e) => { e.stopPropagation(); onSelect?.(net.id); }}
>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<Icon size={10} /> <Icon size={10} />
<span>{net.label}</span> <span>{net.label}</span>
{inactive && ( {inactive && (
<span <span className="chip-mini"
className="chip-mini" style={{ marginLeft: 4, borderColor: 'var(--border)', color: 'rgba(255,255,255,0.45)' }}>
style={{ marginLeft: 4, borderColor: 'var(--border)', color: 'rgba(255,255,255,0.45)' }}
>
INACTIVE INACTIVE
</span> </span>
)} )}
</div> </div>
<span className="cidr">{net.cidr}</span> <span className="cidr">{net.cidr}</span>
</div> </div>
{resizable && ( {resizable && onResizeMouseDown && (
<> <>
<div className="net-resize net-resize-e" /> <div className="net-resize net-resize-e" onMouseDown={onResizeMouseDown(net.id, 'e')} />
<div className="net-resize net-resize-w" /> <div className="net-resize net-resize-w" onMouseDown={onResizeMouseDown(net.id, 'w')} />
<div className="net-resize net-resize-s" /> <div className="net-resize net-resize-s" onMouseDown={onResizeMouseDown(net.id, 's')} />
<div className="net-resize net-resize-n" /> <div className="net-resize net-resize-n" onMouseDown={onResizeMouseDown(net.id, 'n')} />
<div className="net-resize net-resize-se" /> <div className="net-resize net-resize-se" onMouseDown={onResizeMouseDown(net.id, 'se')} />
<div className="net-resize net-resize-sw" /> <div className="net-resize net-resize-sw" onMouseDown={onResizeMouseDown(net.id, 'sw')} />
<div className="net-resize net-resize-ne" /> <div className="net-resize net-resize-ne" onMouseDown={onResizeMouseDown(net.id, 'ne')} />
<div className="net-resize net-resize-nw" /> <div className="net-resize net-resize-nw" onMouseDown={onResizeMouseDown(net.id, 'nw')} />
</> </>
)} )}
{children} {children}

View File

@@ -6,23 +6,27 @@ interface Props {
absX: number; absX: number;
absY: number; absY: number;
selected: boolean; selected: boolean;
dragging?: boolean;
onSelect?: (id: string) => void; onSelect?: (id: string) => void;
onMouseDown?: (id: string) => (e: React.MouseEvent) => void;
} }
const NodeCard: React.FC<Props> = ({ node, absX, absY, selected, onSelect }) => { const NodeCard: React.FC<Props> = ({ node, absX, absY, selected, dragging, onSelect, onMouseDown }) => {
const classes = [ const classes = [
'maze-node', 'maze-node',
node.kind === 'observed' ? 'observed' : '', node.kind === 'observed' ? 'observed' : '',
node.status === 'hot' ? 'hot' : '', node.status === 'hot' ? 'hot' : '',
selected ? 'selected' : '', selected ? 'selected' : '',
dragging ? 'dragging' : '',
].filter(Boolean).join(' '); ].filter(Boolean).join(' ');
const handleDown = (e: React.MouseEvent) => {
onSelect?.(node.id);
onMouseDown?.(node.id)(e);
};
return ( return (
<div <div className={classes} style={{ left: absX, top: absY }} onMouseDown={handleDown}>
className={classes}
style={{ left: absX, top: absY }}
onMouseDown={(e) => { e.stopPropagation(); onSelect?.(node.id); }}
>
<div className="mn-head">{node.name}</div> <div className="mn-head">{node.name}</div>
<div className="mn-sub">{node.archetype.toUpperCase()}</div> <div className="mn-sub">{node.archetype.toUpperCase()}</div>
{node.services.length > 0 && ( {node.services.length > 0 && (

View File

@@ -0,0 +1,206 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { Net, MazeNode, PendingChange } from './types';
export type ResizeHandle = 'e' | 'w' | 'n' | 's' | 'ne' | 'nw' | 'se' | 'sw';
type Drag =
| null
| { type: 'pan'; startX: number; startY: number; panX: number; panY: number }
| { type: 'node'; id: string; offX: number; offY: number }
| { type: 'net'; id: string; offX: number; offY: number }
| { type: 'resize'; id: string; handle: ResizeHandle; startX: number; startY: number; start: Net };
interface Args {
nets: Net[];
nodes: MazeNode[];
setNets: React.Dispatch<React.SetStateAction<Net[]>>;
setNodes: React.Dispatch<React.SetStateAction<MazeNode[]>>;
applyChange: (pc: PendingChange) => void;
canvasRef: React.RefObject<HTMLDivElement | null>;
}
export function useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange, canvasRef }: Args) {
const [pan, setPan] = useState({ x: 0, y: 0 });
const [drag, setDrag] = useState<Drag>(null);
const [dropTargetId, setDropTargetId] = useState<string | null>(null);
/* Refs to avoid re-binding global listeners on every state change. */
const netsRef = useRef(nets);
const nodesRef = useRef(nodes);
const panRef = useRef(pan);
const dragRef = useRef(drag);
useEffect(() => { netsRef.current = nets; }, [nets]);
useEffect(() => { nodesRef.current = nodes; }, [nodes]);
useEffect(() => { panRef.current = pan; }, [pan]);
useEffect(() => { dragRef.current = drag; }, [drag]);
const canvasOriginRef = useRef(() => {
const r = canvasRef.current?.getBoundingClientRect();
return { x: r?.left ?? 0, y: r?.top ?? 0 };
});
/* World-space coords from a client event (applies pan inverse). */
const toWorld = useCallback((clientX: number, clientY: number) => {
const o = canvasOriginRef.current();
const p = panRef.current;
return { x: clientX - o.x - p.x, y: clientY - o.y - p.y };
}, []);
/* ── Mousedown dispatchers ────────────────────────────── */
const onCanvasMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return;
if (e.target !== e.currentTarget) return;
setDrag({ type: 'pan', startX: e.clientX, startY: e.clientY, panX: panRef.current.x, panY: panRef.current.y });
}, []);
const onNodeMouseDown = useCallback((id: string) => (e: React.MouseEvent) => {
if (e.button !== 0) return;
e.stopPropagation();
const node = nodesRef.current.find((n) => n.id === id);
if (!node) return;
const net = netsRef.current.find((nn) => nn.id === node.netId);
if (!net) return;
const w = toWorld(e.clientX, e.clientY);
setDrag({ type: 'node', id, offX: w.x - (net.x + node.x), offY: w.y - (net.y + node.y) });
}, [toWorld]);
const onNetMouseDown = useCallback((id: string) => (e: React.MouseEvent) => {
if (e.button !== 0) return;
e.stopPropagation();
const net = netsRef.current.find((n) => n.id === id);
if (!net) return;
const w = toWorld(e.clientX, e.clientY);
setDrag({ type: 'net', id, offX: w.x - net.x, offY: w.y - net.y });
}, [toWorld]);
const onNetResizeMouseDown = useCallback((id: string, handle: ResizeHandle) => (e: React.MouseEvent) => {
if (e.button !== 0) return;
e.stopPropagation();
const net = netsRef.current.find((n) => n.id === id);
if (!net) return;
setDrag({ type: 'resize', id, handle, startX: e.clientX, startY: e.clientY, start: { ...net } });
}, []);
/* ── Global mousemove / mouseup ───────────────────────── */
useEffect(() => {
const onMove = (e: MouseEvent) => {
const d = dragRef.current;
if (!d) return;
if (d.type === 'pan') {
setPan({ x: d.panX + (e.clientX - d.startX), y: d.panY + (e.clientY - d.startY) });
return;
}
const w = (() => {
const o = canvasOriginRef.current();
const p = panRef.current;
return { x: e.clientX - o.x - p.x, y: e.clientY - o.y - p.y };
})();
if (d.type === 'net') {
setNets((prev) => prev.map((n) => n.id === d.id ? { ...n, x: Math.round(w.x - d.offX), y: Math.round(w.y - d.offY) } : n));
return;
}
if (d.type === 'node') {
const node = nodesRef.current.find((n) => n.id === d.id);
if (!node) return;
const isObserved = node.kind === 'observed';
const targetNet = !isObserved ? netsRef.current.find((net) => {
if (net.id === node.netId) return false;
return w.x >= net.x && w.x <= net.x + net.w && w.y >= net.y && w.y <= net.y + net.h;
}) : undefined;
setDropTargetId(targetNet?.id ?? null);
const parent = netsRef.current.find((n) => n.id === node.netId);
if (!parent) return;
const nx = Math.max(8, Math.round(w.x - d.offX - parent.x));
const ny = Math.max(28, Math.round(w.y - d.offY - parent.y));
setNodes((prev) => prev.map((n) => n.id === d.id ? { ...n, x: nx, y: ny } : n));
return;
}
if (d.type === 'resize') {
const dx = e.clientX - d.startX;
const dy = e.clientY - d.startY;
setNets((prev) => prev.map((n) => {
if (n.id !== d.id) return n;
let { x, y, w: width, h: height } = d.start;
const MIN_W = 220, MIN_H = 140;
if (d.handle.includes('e')) width = Math.max(MIN_W, d.start.w + dx);
if (d.handle.includes('s')) height = Math.max(MIN_H, d.start.h + dy);
if (d.handle.includes('w')) {
width = Math.max(MIN_W, d.start.w - dx);
x = d.start.x + (d.start.w - width);
}
if (d.handle.includes('n')) {
height = Math.max(MIN_H, d.start.h - dy);
y = d.start.y + (d.start.h - height);
}
return { ...n, x, y, w: width, h: height };
}));
return;
}
};
const onUp = () => {
const d = dragRef.current;
if (!d) return;
if (d.type === 'node') {
const node = nodesRef.current.find((n) => n.id === d.id);
const target = dropTargetId;
if (node && node.kind === 'decky' && target && target !== node.netId) {
const parentOld = netsRef.current.find((nn) => nn.id === node.netId);
const parentNew = netsRef.current.find((nn) => nn.id === target);
if (parentOld && parentNew) {
const absX = parentOld.x + node.x;
const absY = parentOld.y + node.y;
const relX = Math.max(8, absX - parentNew.x);
const relY = Math.max(28, absY - parentNew.y);
setNodes((prev) => prev.map((n) => n.id === d.id ? { ...n, netId: target, x: relX, y: relY } : n));
applyChange({ op: 'detach_decky', payload: { nodeId: d.id, netId: node.netId } });
applyChange({ op: 'attach_decky', payload: {
nodeId: d.id, netId: target, archetype: node.archetype, name: node.name,
x: relX, y: relY, services: node.services,
}});
}
} else if (node && node.kind === 'decky') {
applyChange({ op: 'update_decky', payload: { nodeId: node.id, patch: { x: node.x, y: node.y } } });
}
} else if (d.type === 'net') {
const net = netsRef.current.find((n) => n.id === d.id);
if (net) applyChange({ op: 'update_lan', payload: { id: net.id, patch: { x: net.x, y: net.y } } });
} else if (d.type === 'resize') {
const net = netsRef.current.find((n) => n.id === d.id);
if (net) applyChange({ op: 'update_lan', payload: { id: net.id, patch: { x: net.x, y: net.y, w: net.w, h: net.h } } });
}
setDropTargetId(null);
setDrag(null);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
}, [applyChange, setNets, setNodes, dropTargetId]);
const resetPan = useCallback(() => setPan({ x: 0, y: 0 }), []);
return {
pan,
dropTargetId,
dragging: drag !== null,
onCanvasMouseDown,
onNodeMouseDown,
onNetMouseDown,
onNetResizeMouseDown,
resetPan,
};
}