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) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
import { ConnectionEdge } from './ConnectionEdge'
|
import { ConnectionEdge } from './ConnectionEdge'
|
||||||
|
import { AnimatedSvgEdge } from '../ui/animated-svg-edge'
|
||||||
|
|
||||||
export const edgeTypes = {
|
export const edgeTypes = {
|
||||||
connection: ConnectionEdge,
|
connection: ConnectionEdge,
|
||||||
|
animated: AnimatedSvgEdge,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { DeviceNode } from './DeviceNode'
|
import { DeviceNode } from './DeviceNode'
|
||||||
|
import { GroupNode } from '../ui/labeled-group-node'
|
||||||
|
|
||||||
export const nodeTypes = {
|
export const nodeTypes = {
|
||||||
device: DeviceNode,
|
device: DeviceNode,
|
||||||
|
group: GroupNode,
|
||||||
}
|
}
|
||||||
|
|||||||
131
frontend/src/components/network/ui/animated-svg-edge.tsx
Normal file
131
frontend/src/components/network/ui/animated-svg-edge.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<BaseEdge
|
||||||
|
path={edgePath}
|
||||||
|
style={{
|
||||||
|
stroke: color,
|
||||||
|
strokeWidth: props.selected ? 3 : 2,
|
||||||
|
...(connectionType === 'wifi' || connectionType === 'wan' || connectionType === 'vpn'
|
||||||
|
? { strokeDasharray: connectionType === 'wifi' ? '3,3' : '8,4' }
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<circle r={0} fill={color}>
|
||||||
|
<animateMotion
|
||||||
|
path={edgePath}
|
||||||
|
calcMode="linear"
|
||||||
|
{...motionProps}
|
||||||
|
/>
|
||||||
|
<animate
|
||||||
|
attributeName="r"
|
||||||
|
values="0;3;3;3;0"
|
||||||
|
keyTimes="0;0.05;0.5;0.95;1"
|
||||||
|
dur={motionProps.dur}
|
||||||
|
repeatCount={motionProps.repeatCount}
|
||||||
|
/>
|
||||||
|
</circle>
|
||||||
|
{shape === 'package' && (
|
||||||
|
<rect x={-4} y={-4} width={8} height={8} rx={2} fill={color} opacity={0.8}>
|
||||||
|
<animateMotion
|
||||||
|
path={edgePath}
|
||||||
|
calcMode="linear"
|
||||||
|
{...motionProps}
|
||||||
|
/>
|
||||||
|
</rect>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AnimatedSvgEdge = memo(AnimatedSvgEdgeComponent)
|
||||||
59
frontend/src/components/network/ui/labeled-group-node.tsx
Normal file
59
frontend/src/components/network/ui/labeled-group-node.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="h-full w-full" {...props}>
|
||||||
|
<div className={cn('bg-card text-muted-foreground w-fit p-2 text-[10px] font-semibold uppercase tracking-wider', className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupNodeData {
|
||||||
|
label?: string
|
||||||
|
groupType?: string
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GroupNodeProps = Partial<NodeProps> & {
|
||||||
|
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 (
|
||||||
|
<BaseNode
|
||||||
|
className={cn(
|
||||||
|
'h-full min-h-[200px] min-w-[300px] overflow-hidden rounded-lg bg-elevated/30 border-default/50',
|
||||||
|
selected && 'border-accent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Panel className="m-0 p-0" position="top-left">
|
||||||
|
<GroupNodeLabel className={getLabelClassName('top-left')}>
|
||||||
|
{label}
|
||||||
|
</GroupNodeLabel>
|
||||||
|
</Panel>
|
||||||
|
</BaseNode>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user