From 3aaf0e58aa38fcb88accab0f9a35888f7b3755f9 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 4 Apr 2026 14:22:45 +0000 Subject: [PATCH] feat: add LabeledGroupNode and AnimatedSvgEdge components GroupNode for subnet/VLAN/site grouping with positioned label badge. AnimatedSvgEdge for traffic flow visualization with animated SVG shape along edge path. Both registered in type maps. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/network/edges/edgeTypes.ts | 2 + .../src/components/network/nodes/nodeTypes.ts | 2 + .../network/ui/animated-svg-edge.tsx | 131 ++++++++++++++++++ .../network/ui/labeled-group-node.tsx | 59 ++++++++ 4 files changed, 194 insertions(+) create mode 100644 frontend/src/components/network/ui/animated-svg-edge.tsx create mode 100644 frontend/src/components/network/ui/labeled-group-node.tsx diff --git a/frontend/src/components/network/edges/edgeTypes.ts b/frontend/src/components/network/edges/edgeTypes.ts index 0c23c2f8..eb523a77 100644 --- a/frontend/src/components/network/edges/edgeTypes.ts +++ b/frontend/src/components/network/edges/edgeTypes.ts @@ -1,5 +1,7 @@ import { ConnectionEdge } from './ConnectionEdge' +import { AnimatedSvgEdge } from '../ui/animated-svg-edge' export const edgeTypes = { connection: ConnectionEdge, + animated: AnimatedSvgEdge, } diff --git a/frontend/src/components/network/nodes/nodeTypes.ts b/frontend/src/components/network/nodes/nodeTypes.ts index d698081c..eb0a4462 100644 --- a/frontend/src/components/network/nodes/nodeTypes.ts +++ b/frontend/src/components/network/nodes/nodeTypes.ts @@ -1,5 +1,7 @@ import { DeviceNode } from './DeviceNode' +import { GroupNode } from '../ui/labeled-group-node' export const nodeTypes = { device: DeviceNode, + group: GroupNode, } diff --git a/frontend/src/components/network/ui/animated-svg-edge.tsx b/frontend/src/components/network/ui/animated-svg-edge.tsx new file mode 100644 index 00000000..c5977943 --- /dev/null +++ b/frontend/src/components/network/ui/animated-svg-edge.tsx @@ -0,0 +1,131 @@ +import { memo } from 'react' +import { + BaseEdge, + getSmoothStepPath, + getStraightPath, + getBezierPath, + type EdgeProps, +} from '@xyflow/react' + +interface AnimatedEdgeData { + connectionType?: string + duration?: number + direction?: 'forward' | 'reverse' | 'alternate' | 'alternate-reverse' + path?: 'bezier' | 'smoothstep' | 'step' | 'straight' + repeat?: number | 'indefinite' + shape?: 'circle' | 'package' + speed?: string | null + notes?: string | null + [key: string]: unknown +} + +const CONNECTION_COLORS: Record = { + ethernet: '#60a5fa', + fiber: '#34d399', + wifi: '#a78bfa', + vpn: '#eab308', + vlan: '#848b9b', + wan: '#f87171', +} + +const DEFAULT_COLOR = '#848b9b' + +function getPath( + props: EdgeProps, + pathType: string, +): [string, number, number] { + const params = { + sourceX: props.sourceX, + sourceY: props.sourceY, + sourcePosition: props.sourcePosition, + targetX: props.targetX, + targetY: props.targetY, + targetPosition: props.targetPosition, + } + + switch (pathType) { + case 'bezier': { + const [path, labelX, labelY] = getBezierPath(params) + return [path, labelX, labelY] + } + case 'straight': { + const [path, labelX, labelY] = getStraightPath(params) + return [path, labelX, labelY] + } + default: { + const [path, labelX, labelY] = getSmoothStepPath(params) + return [path, labelX, labelY] + } + } +} + +function getAnimateMotionProps(data: AnimatedEdgeData) { + const duration = data.duration ?? 2 + const direction = data.direction ?? 'forward' + const repeat = data.repeat ?? 'indefinite' + + const keyPoints: Record = { + forward: '0;1', + reverse: '1;0', + alternate: '0;1', + 'alternate-reverse': '1;0', + } + + return { + dur: `${duration}s`, + repeatCount: String(repeat), + keyPoints: keyPoints[direction] || '0;1', + keyTimes: '0;1', + } +} + +function AnimatedSvgEdgeComponent(props: EdgeProps) { + const data = (props.data || {}) as AnimatedEdgeData + const connectionType = data.connectionType || 'ethernet' + const color = CONNECTION_COLORS[connectionType] || DEFAULT_COLOR + const pathType = data.path ?? 'smoothstep' + const shape = data.shape ?? 'circle' + + const [edgePath] = getPath(props, pathType) + const motionProps = getAnimateMotionProps(data) + + return ( + <> + + + + + + {shape === 'package' && ( + + + + )} + + ) +} + +export const AnimatedSvgEdge = memo(AnimatedSvgEdgeComponent) diff --git a/frontend/src/components/network/ui/labeled-group-node.tsx b/frontend/src/components/network/ui/labeled-group-node.tsx new file mode 100644 index 00000000..060335c0 --- /dev/null +++ b/frontend/src/components/network/ui/labeled-group-node.tsx @@ -0,0 +1,59 @@ +import type { ReactNode, ComponentProps } from 'react' +import { Panel, type NodeProps, type PanelPosition } from '@xyflow/react' +import { BaseNode } from './base-node' +import { cn } from '@/lib/utils' + +export type GroupNodeLabelProps = ComponentProps<'div'> + +export function GroupNodeLabel({ children, className, ...props }: GroupNodeLabelProps) { + return ( +
+
+ {children} +
+
+ ) +} + +export interface GroupNodeData { + label?: string + groupType?: string + [key: string]: unknown +} + +export type GroupNodeProps = Partial & { + label?: ReactNode + position?: PanelPosition +} + +function getLabelClassName(position?: PanelPosition): string { + switch (position) { + case 'top-left': return 'rounded-br-sm' + case 'top-center': return 'rounded-b-sm' + case 'top-right': return 'rounded-bl-sm' + case 'bottom-left': return 'rounded-tr-sm' + case 'bottom-right': return 'rounded-tl-sm' + case 'bottom-center': return 'rounded-t-sm' + default: return 'rounded-br-sm' + } +} + +export function GroupNode({ data, selected }: NodeProps) { + const nodeData = data as unknown as GroupNodeData + const label = nodeData.label || 'Group' + + return ( + + + + {label} + + + + ) +}