feat: add Basic animation type for edges

Adds a new "basic" animation mode that uses React Flow's native animated
dashes — the entire stroke moves as dashes, with no solid line underneath.
Distinct from "snake" (moving dot) and "flow" (overlay on solid line).

- Frontend: new Basic button in EdgeModal, animMode mapping, passes
  animated={true} to BaseEdge when mode is "basic"
- Backend: normalize_animated accepts "basic" as a valid value
- Tests: EdgeModal + canvas round-trip tests for basic mode
This commit is contained in:
Pouzor
2026-04-09 15:46:52 +02:00
parent 4976f2e694
commit 4844576c3b
6 changed files with 36 additions and 9 deletions
@@ -233,9 +233,10 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}),
}
const animMode: 'none' | 'snake' | 'flow' =
const animMode: 'none' | 'snake' | 'flow' | 'basic' =
data?.animated === true || data?.animated === 'snake' ? 'snake' :
data?.animated === 'flow' ? 'flow' : 'none'
data?.animated === 'flow' ? 'flow' :
data?.animated === 'basic' ? 'basic' : 'none'
const animColor = customColor ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : edgeColors[edgeType as keyof typeof edgeColors] as string)
@@ -245,7 +246,7 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
return (
<>
<BaseEdge id={id} path={edgePath} style={style} interactionWidth={16} />
<BaseEdge id={id} path={edgePath} style={style} interactionWidth={16} animated={animMode === 'basic'} />
{animMode === 'snake' && (
<path
+5 -4
View File
@@ -10,11 +10,12 @@ import { EDGE_DEFAULT_COLORS } from '@/utils/edgeColors'
const EDGE_TYPES = Object.entries(EDGE_TYPE_LABELS) as [EdgeType, string][]
type AnimMode = 'none' | 'snake' | 'flow'
type AnimMode = 'none' | 'basic' | 'snake' | 'flow'
function toAnimMode(v: EdgeData['animated']): AnimMode {
if (v === true || v === 'snake') return 'snake'
if (v === 'flow') return 'flow'
if (v === 'basic') return 'basic'
return 'none'
}
@@ -127,7 +128,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Animation</Label>
<div className="flex rounded-md overflow-hidden border border-[#30363d]">
{(['none', 'snake', 'flow'] as AnimMode[]).map((mode, i) => (
{(['none', 'basic', 'snake', 'flow'] as AnimMode[]).map((mode, i) => (
<button
key={mode}
type="button"
@@ -136,10 +137,10 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
style={{
background: animation === mode ? '#00d4ff22' : '#21262d',
color: animation === mode ? '#00d4ff' : '#8b949e',
borderRight: i < 2 ? '1px solid #30363d' : undefined,
borderRight: i < 3 ? '1px solid #30363d' : undefined,
}}
>
{mode === 'none' ? 'None' : mode === 'snake' ? 'Snake' : 'Flow'}
{mode === 'none' ? 'None' : mode === 'basic' ? 'Basic' : mode === 'snake' ? 'Snake' : 'Flow'}
</button>
))}
</div>
@@ -122,6 +122,21 @@ describe('EdgeModal', () => {
expect(onSubmit.mock.calls[0][0].animated).toBe('flow')
})
it('selecting Basic sends animated: "basic"', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('Basic'))
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].animated).toBe('basic')
})
it('pre-fills animation from initial "basic" string', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ animated: 'basic' }} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].animated).toBe('basic')
})
it('selecting None after Snake omits animated from payload', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)