feat(web/mazenet): render canvas — net boxes, node cards, bezier edges, topology loader
This commit is contained in:
140
decnet_web/src/components/MazeNET/Canvas.tsx
Normal file
140
decnet_web/src/components/MazeNET/Canvas.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import NetBox from './NetBox';
|
||||||
|
import NodeCard from './NodeCard';
|
||||||
|
import type { Net, MazeNode, Edge } from './types';
|
||||||
|
import type { Selection } from './Inspector';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
nets: Net[];
|
||||||
|
nodes: MazeNode[];
|
||||||
|
edges: Edge[];
|
||||||
|
selection: Selection;
|
||||||
|
setSelection: (s: Selection) => void;
|
||||||
|
pan?: { x: number; y: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
const NODE_W = 140;
|
||||||
|
const NODE_HEAD_H = 22;
|
||||||
|
|
||||||
|
const Canvas: React.FC<Props> = ({ nets, nodes, edges, selection, setSelection, pan = { x: 0, y: 0 } }) => {
|
||||||
|
const netById = useMemo(() => new Map(nets.map((n) => [n.id, n])), [nets]);
|
||||||
|
|
||||||
|
const absPos = (node: MazeNode) => {
|
||||||
|
const net = netById.get(node.netId);
|
||||||
|
return { x: (net?.x ?? 0) + node.x, y: (net?.y ?? 0) + node.y };
|
||||||
|
};
|
||||||
|
|
||||||
|
/* nets touched by any edge */
|
||||||
|
const activeNetIds = useMemo(() => {
|
||||||
|
const nodeNet = new Map(nodes.map((n) => [n.id, n.netId]));
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const e of edges) {
|
||||||
|
const a = nodeNet.get(e.from); const b = nodeNet.get(e.to);
|
||||||
|
if (a) ids.add(a); if (b) ids.add(b);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}, [nodes, edges]);
|
||||||
|
|
||||||
|
const selNetId = selection?.type === 'net' ? selection.id : null;
|
||||||
|
const selNodeId = selection?.type === 'node' ? selection.id : null;
|
||||||
|
const selEdgeId = selection?.type === 'edge' ? selection.id : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="maze-canvas-wrap"
|
||||||
|
onMouseDown={(e) => { if (e.target === e.currentTarget) setSelection(null); }}
|
||||||
|
>
|
||||||
|
<div className="maze-grid-bg">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<pattern id="maze-grid-pat" x={pan.x} y={pan.y} width="40" height="40" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="var(--grid-line)" strokeWidth="1" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="url(#maze-grid-pat)" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="maze-pan-layer" style={{ transform: `translate(${pan.x}px, ${pan.y}px)` }}>
|
||||||
|
<svg className="maze-svg">
|
||||||
|
<defs>
|
||||||
|
<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" />
|
||||||
|
</marker>
|
||||||
|
<marker id="arrow-violet" 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="#ee82ee" />
|
||||||
|
</marker>
|
||||||
|
<marker id="arrow-alert" 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="#ff4141" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
{edges.map((e) => {
|
||||||
|
const from = nodes.find((n) => n.id === e.from);
|
||||||
|
const to = nodes.find((n) => n.id === e.to);
|
||||||
|
if (!from || !to) return null;
|
||||||
|
const a = absPos(from); const b = absPos(to);
|
||||||
|
const x1 = a.x + NODE_W, y1 = a.y + NODE_HEAD_H;
|
||||||
|
const x2 = b.x, y2 = b.y + NODE_HEAD_H;
|
||||||
|
const cx = (x1 + x2) / 2;
|
||||||
|
const d = `M${x1},${y1} C${cx},${y1} ${cx},${y2} ${x2},${y2}`;
|
||||||
|
const klass = e.traffic === 'hot' ? 'hot' : e.traffic === 'active' ? 'active' : '';
|
||||||
|
const marker = e.traffic === 'hot' ? 'arrow-alert' : e.traffic === 'active' ? 'arrow-violet' : 'arrow-matrix';
|
||||||
|
const isSel = e.id === selEdgeId;
|
||||||
|
return (
|
||||||
|
<g key={e.id} style={{ pointerEvents: 'auto' }}
|
||||||
|
onClick={(ev) => { ev.stopPropagation(); setSelection({ type: 'edge', id: e.id }); }}>
|
||||||
|
<path d={d} className={`maze-edge ${klass} maze-edge-dash`} markerEnd={`url(#${marker})`}
|
||||||
|
style={{ strokeWidth: isSel ? 2.5 : 1.5 }} />
|
||||||
|
<path d={d} stroke="transparent" strokeWidth="12" fill="none" style={{ cursor: 'pointer' }} />
|
||||||
|
{e.label && (
|
||||||
|
<text x={cx} y={(y1 + y2) / 2 - 6} textAnchor="middle"
|
||||||
|
fill={e.traffic === 'hot' ? '#ff4141' : '#ee82ee'}
|
||||||
|
fontSize="9" fontFamily="var(--font-mono)" letterSpacing="1">
|
||||||
|
{e.label}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div className="maze-nodes">
|
||||||
|
{nets.map((net) => {
|
||||||
|
const inactive = net.kind !== 'internet' && !activeNetIds.has(net.id);
|
||||||
|
return (
|
||||||
|
<NetBox
|
||||||
|
key={net.id}
|
||||||
|
net={net}
|
||||||
|
selected={net.id === selNetId}
|
||||||
|
dropTarget={false}
|
||||||
|
inactive={inactive}
|
||||||
|
onSelect={(id) => setSelection({ type: 'net', id })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{nodes.map((n) => {
|
||||||
|
const p = absPos(n);
|
||||||
|
return (
|
||||||
|
<NodeCard
|
||||||
|
key={n.id}
|
||||||
|
node={n}
|
||||||
|
absX={p.x}
|
||||||
|
absY={p.y}
|
||||||
|
selected={n.id === selNodeId}
|
||||||
|
onSelect={(id) => setSelection({ type: 'node', id })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="maze-legend">
|
||||||
|
<div className="lg-row"><span className="lg-swatch" style={{ background: '#ff4141' }} /> HOT</div>
|
||||||
|
<div className="lg-row"><span className="lg-swatch" style={{ background: '#ee82ee' }} /> ACTIVE</div>
|
||||||
|
<div className="lg-row"><span className="lg-swatch" style={{ background: '#00ff41' }} /> IDLE</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Canvas;
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
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';
|
||||||
import Palette from './Palette';
|
import Palette from './Palette';
|
||||||
|
import Canvas from './Canvas';
|
||||||
import Inspector from './Inspector';
|
import Inspector from './Inspector';
|
||||||
import type { Selection } from './Inspector';
|
import type { Selection } from './Inspector';
|
||||||
import { DEFAULT_SERVICES, DEMO_NETS, DEMO_NODES, DEMO_EDGES } from './data';
|
import { DEFAULT_SERVICES, DEMO_NETS, DEMO_NODES, DEMO_EDGES } from './data';
|
||||||
@@ -11,6 +13,8 @@ import { useMazeApi } from './useMazeApi';
|
|||||||
|
|
||||||
const MazeNET: React.FC = () => {
|
const MazeNET: React.FC = () => {
|
||||||
const api = useMazeApi();
|
const api = useMazeApi();
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const topologyId = params.get('topology');
|
||||||
|
|
||||||
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);
|
||||||
@@ -19,15 +23,40 @@ const MazeNET: React.FC = () => {
|
|||||||
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);
|
||||||
|
|
||||||
|
/* Load service catalog from API (fall back to defaults if 401/offline). */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
api.getServices().then((s) => { if (!cancelled) setServices(s); }).catch(() => {});
|
api.getServices().then((s) => { if (!cancelled) setServices(s); }).catch(() => {});
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [api]);
|
}, [api]);
|
||||||
|
|
||||||
|
/* If ?topology=<id> is present, hydrate from the real backend. */
|
||||||
|
useEffect(() => {
|
||||||
|
if (!topologyId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
api.getTopology(topologyId)
|
||||||
|
.then((h) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setNets(h.nets); setNodes(h.nodes); setEdges(h.edges);
|
||||||
|
setSelection(null);
|
||||||
|
setLoadErr(null);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) setLoadErr(err?.message ?? 'topology load failed');
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [api, topologyId]);
|
||||||
|
|
||||||
const onReset = () => {
|
const onReset = () => {
|
||||||
setNets(DEMO_NETS); setNodes(DEMO_NODES); setEdges(DEMO_EDGES);
|
if (topologyId) {
|
||||||
|
api.getTopology(topologyId).then((h) => {
|
||||||
|
setNets(h.nets); setNodes(h.nodes); setEdges(h.edges);
|
||||||
|
}).catch(() => {});
|
||||||
|
} else {
|
||||||
|
setNets(DEMO_NETS); setNodes(DEMO_NODES); setEdges(DEMO_EDGES);
|
||||||
|
}
|
||||||
setSelection(null);
|
setSelection(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,8 +66,10 @@ const MazeNET: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<h1>MAZENET</h1>
|
<h1>MAZENET</h1>
|
||||||
<div className="maze-page-sub">
|
<div className="maze-page-sub">
|
||||||
NETWORK OF NETWORKS · {nets.length} NETS · {nodes.length} NODES · {edges.length} PATHS ·{' '}
|
{topologyId ? `TOPOLOGY ${topologyId} · ` : 'DEMO · '}
|
||||||
|
{nets.length} NETS · {nodes.length} NODES · {edges.length} PATHS ·{' '}
|
||||||
{pending.length > 0 ? `${pending.length} UNCOMMITTED` : 'LIVE'}
|
{pending.length > 0 ? `${pending.length} UNCOMMITTED` : 'LIVE'}
|
||||||
|
{loadErr && <span className="alert-text"> · {loadErr}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="maze-page-actions">
|
<div className="maze-page-actions">
|
||||||
@@ -58,7 +89,7 @@ const MazeNET: React.FC = () => {
|
|||||||
type="button"
|
type="button"
|
||||||
className="maze-btn"
|
className="maze-btn"
|
||||||
disabled={pending.length === 0}
|
disabled={pending.length === 0}
|
||||||
onClick={() => api.commit('', pending)}
|
onClick={() => api.commit(topologyId ?? '', pending)}
|
||||||
>
|
>
|
||||||
<UploadCloud size={12} /> COMMIT {pending.length > 0 ? `(${pending.length})` : ''}
|
<UploadCloud size={12} /> COMMIT {pending.length > 0 ? `(${pending.length})` : ''}
|
||||||
</button>
|
</button>
|
||||||
@@ -70,21 +101,13 @@ const MazeNET: React.FC = () => {
|
|||||||
style={{ gridTemplateColumns: inspectorOpen ? '240px 1fr 320px' : '240px 1fr' }}
|
style={{ gridTemplateColumns: inspectorOpen ? '240px 1fr 320px' : '240px 1fr' }}
|
||||||
>
|
>
|
||||||
<Palette services={services} />
|
<Palette services={services} />
|
||||||
|
<Canvas
|
||||||
<div className="maze-canvas-wrap">
|
nets={nets}
|
||||||
<div className="maze-grid-bg">
|
nodes={nodes}
|
||||||
<svg xmlns="http://www.w3.org/2000/svg">
|
edges={edges}
|
||||||
<defs>
|
selection={selection}
|
||||||
<pattern id="maze-grid-pat" x={0} y={0} width="40" height="40" patternUnits="userSpaceOnUse">
|
setSelection={setSelection}
|
||||||
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="var(--grid-line)" strokeWidth="1" />
|
/>
|
||||||
</pattern>
|
|
||||||
</defs>
|
|
||||||
<rect width="100%" height="100%" fill="url(#maze-grid-pat)" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="maze-empty-hint">CANVAS COMES ONLINE IN STEP 4</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{inspectorOpen && (
|
{inspectorOpen && (
|
||||||
<Inspector
|
<Inspector
|
||||||
selection={selection}
|
selection={selection}
|
||||||
|
|||||||
69
decnet_web/src/components/MazeNET/NetBox.tsx
Normal file
69
decnet_web/src/components/MazeNET/NetBox.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Globe, GitMerge } from 'lucide-react';
|
||||||
|
import type { Net } from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
net: Net;
|
||||||
|
selected: boolean;
|
||||||
|
dropTarget: boolean;
|
||||||
|
inactive: boolean;
|
||||||
|
onSelect?: (id: string) => void;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NetBox: React.FC<Props> = ({ net, selected, dropTarget, inactive, onSelect, children }) => {
|
||||||
|
const classes = [
|
||||||
|
'maze-net-box',
|
||||||
|
net.kind === 'internet' ? 'internet' : '',
|
||||||
|
selected ? 'selected' : '',
|
||||||
|
dropTarget ? 'drop-target' : '',
|
||||||
|
inactive ? 'inactive' : '',
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
const Icon = net.kind === 'internet' ? Globe : GitMerge;
|
||||||
|
const resizable = net.kind !== 'internet';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classes}
|
||||||
|
style={{ left: net.x, top: net.y, width: net.w, height: net.h }}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (e.target === e.currentTarget) { e.stopPropagation(); onSelect?.(net.id); }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="maze-net-box-head"
|
||||||
|
onMouseDown={(e) => { e.stopPropagation(); onSelect?.(net.id); }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<Icon size={10} />
|
||||||
|
<span>{net.label}</span>
|
||||||
|
{inactive && (
|
||||||
|
<span
|
||||||
|
className="chip-mini"
|
||||||
|
style={{ marginLeft: 4, borderColor: 'var(--border)', color: 'rgba(255,255,255,0.45)' }}
|
||||||
|
>
|
||||||
|
INACTIVE
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="cidr">{net.cidr}</span>
|
||||||
|
</div>
|
||||||
|
{resizable && (
|
||||||
|
<>
|
||||||
|
<div className="net-resize net-resize-e" />
|
||||||
|
<div className="net-resize net-resize-w" />
|
||||||
|
<div className="net-resize net-resize-s" />
|
||||||
|
<div className="net-resize net-resize-n" />
|
||||||
|
<div className="net-resize net-resize-se" />
|
||||||
|
<div className="net-resize net-resize-sw" />
|
||||||
|
<div className="net-resize net-resize-ne" />
|
||||||
|
<div className="net-resize net-resize-nw" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NetBox;
|
||||||
46
decnet_web/src/components/MazeNET/NodeCard.tsx
Normal file
46
decnet_web/src/components/MazeNET/NodeCard.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { MazeNode } from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
node: MazeNode;
|
||||||
|
absX: number;
|
||||||
|
absY: number;
|
||||||
|
selected: boolean;
|
||||||
|
onSelect?: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NodeCard: React.FC<Props> = ({ node, absX, absY, selected, onSelect }) => {
|
||||||
|
const classes = [
|
||||||
|
'maze-node',
|
||||||
|
node.kind === 'observed' ? 'observed' : '',
|
||||||
|
node.status === 'hot' ? 'hot' : '',
|
||||||
|
selected ? 'selected' : '',
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classes}
|
||||||
|
style={{ left: absX, top: absY }}
|
||||||
|
onMouseDown={(e) => { e.stopPropagation(); onSelect?.(node.id); }}
|
||||||
|
>
|
||||||
|
<div className="mn-head">{node.name}</div>
|
||||||
|
<div className="mn-sub">{node.archetype.toUpperCase()}</div>
|
||||||
|
{node.services.length > 0 && (
|
||||||
|
<div className="mn-services">
|
||||||
|
{node.services.map((s) => (
|
||||||
|
<span key={s} className={`service-tag ${node.status === 'hot' ? 'hot' : ''}`}>
|
||||||
|
{s}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{node.kind === 'decky' && <>
|
||||||
|
<span className="mn-port in" />
|
||||||
|
<span className="mn-port out" />
|
||||||
|
</>}
|
||||||
|
{node.kind === 'observed' && <span className="mn-port out" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NodeCard;
|
||||||
Reference in New Issue
Block a user