feat: arrowhead endpoints for edges + fix parallel edges not rendering

Add optional filled-triangle arrowheads at either end of an edge,
independently toggleable per edge (EdgeModal) and as per-edge-type
defaults (CustomStyleModal). Arrowheads are custom inline <marker> defs
filled with the live stroke colour so they recolour reactively with
custom_color / vlan / selected state. Persisted frontend (serializer)
and backend (edge columns + schemas + runtime migration).

Also fix two dedupe layers that silently dropped legitimate parallel
links between the same two devices:
- store: React Flow addEdge() connectionExists dropped a second edge
  with matching source+target when handles were null/equal. Build the
  edge with a unique id and append directly.
- render: rewireEdgesForCollapse deduped ALL edges by src->tgt key even
  when nothing was collapsed, filtering real parallel edges out of the
  visible set. Restrict the anti-mesh dedupe to rewired collapse stubs.

Tests: marker render, per-edge/per-type UI, store apply, serializer
round-trip, backend edge/canvas persistence, parallel-edge regressions.

ha-relevant: yes
This commit is contained in:
Pouzor
2026-07-05 01:16:26 +02:00
parent ae2d3e1eab
commit 1cf525844b
20 changed files with 371 additions and 14 deletions
@@ -0,0 +1,72 @@
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import { ReactFlowProvider } from '@xyflow/react'
import type { EdgeProps, Edge } from '@xyflow/react'
import { HomelableEdge } from '../index'
import type { EdgeData } from '@/types'
/**
* Arrowhead endpoints: filled-triangle <marker> defs, independently toggleable
* at start/end, filled with the live stroke color, and referenced by BaseEdge
* via markerStart/markerEnd URLs.
*/
function renderEdge(data: Partial<EdgeData> = {}, selected = false) {
const props = {
id: 'e1',
source: 'a',
target: 'b',
sourceX: 0,
sourceY: 0,
targetX: 100,
targetY: 100,
sourcePosition: 'bottom',
targetPosition: 'top',
data: { type: 'ethernet', ...data } as EdgeData,
selected,
} as unknown as EdgeProps<Edge<EdgeData>>
return render(
<ReactFlowProvider>
<svg>
<HomelableEdge {...props} />
</svg>
</ReactFlowProvider>,
)
}
describe('HomelableEdge arrow markers', () => {
it('renders no marker defs by default', () => {
const { container } = renderEdge()
expect(container.querySelector('marker')).toBeNull()
})
it('renders an end marker referenced by the edge path', () => {
const { container } = renderEdge({ marker_end: true })
const marker = container.querySelector('#arrow-end-e1')
expect(marker).toBeTruthy()
expect(container.querySelector('#arrow-start-e1')).toBeNull()
const referenced = Array.from(container.querySelectorAll('path')).some(
(p) => p.getAttribute('marker-end') === 'url(#arrow-end-e1)',
)
expect(referenced).toBe(true)
})
it('renders a start marker with reversed orientation', () => {
const { container } = renderEdge({ marker_start: true })
const marker = container.querySelector('#arrow-start-e1')
expect(marker).toBeTruthy()
expect(marker?.getAttribute('orient')).toBe('auto-start-reverse')
})
it('renders both markers when both ends enabled', () => {
const { container } = renderEdge({ marker_start: true, marker_end: true })
expect(container.querySelector('#arrow-start-e1')).toBeTruthy()
expect(container.querySelector('#arrow-end-e1')).toBeTruthy()
})
it('fills the marker with the resolved custom color', () => {
const { container } = renderEdge({ marker_end: true, custom_color: '#ff6e00' })
const fill = container.querySelector('#arrow-end-e1 path')?.getAttribute('fill')
expect(fill).toBe('#ff6e00')
})
})
+40 -1
View File
@@ -351,9 +351,48 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle
? segmentMidpoints(sourceX, sourceY, waypoints, targetX, targetY, pathStyle, sourcePosition)
: []
// ── Arrowheads ─────────────────────────────────────────────────────────────
// Custom inline <marker> defs filled with the live strokeColor so they recolor
// reactively (custom_color / vlan / selected). Sized from the stroke width.
const markerStart = data?.marker_start === true
const markerEnd = data?.marker_end === true
const strokeW = (style.strokeWidth as number) ?? 2
const markerSize = 6 + strokeW * 2
const startMarkerId = `arrow-start-${id}`
const endMarkerId = `arrow-end-${id}`
const arrowMarker = (markerId: string, orient: string) => (
<marker
id={markerId}
viewBox="0 0 10 10"
refX={9}
refY={5}
markerWidth={markerSize}
markerHeight={markerSize}
markerUnits="userSpaceOnUse"
orient={orient}
>
<path d="M 0 0 L 10 5 L 0 10 z" fill={strokeColor} />
</marker>
)
return (
<>
<BaseEdge id={id} path={edgePath} style={animMode === 'basic' ? { ...style, stroke: 'transparent' } : style} interactionWidth={16} />
{(markerStart || markerEnd) && (
<defs>
{markerStart && arrowMarker(startMarkerId, 'auto-start-reverse')}
{markerEnd && arrowMarker(endMarkerId, 'auto')}
</defs>
)}
<BaseEdge
id={id}
path={edgePath}
style={animMode === 'basic' ? { ...style, stroke: 'transparent' } : style}
interactionWidth={16}
markerStart={markerStart ? `url(#${startMarkerId})` : undefined}
markerEnd={markerEnd ? `url(#${endMarkerId})` : undefined}
/>
{animMode === 'basic' && (
<path
@@ -64,6 +64,8 @@ function defaultEdgeStyle(edgeType: EdgeType): EdgeTypeStyle {
opacity: 1,
pathStyle: 'bezier',
animated: 'none',
arrowStart: false,
arrowEnd: false,
}
}
@@ -279,6 +281,28 @@ function EdgeEditor({ edgeType, style, onChange, onApplyToExisting }: EdgeEditor
<option value="snake">Snake</option>
</select>
</div>
<div>
<div className="text-xs text-[#8b949e] mb-2">Arrows</div>
<div className="flex gap-2">
{([['Start', 'arrowStart'], ['End', 'arrowEnd']] as [string, 'arrowStart' | 'arrowEnd'][]).map(([label, key]) => (
<button
key={key}
type="button"
onClick={() => set(key, !style[key])}
aria-pressed={style[key]}
className="px-3 py-1 text-xs rounded border transition-colors"
style={{
borderColor: style[key] ? '#00d4ff' : '#30363d',
background: style[key] ? '#00d4ff22' : 'transparent',
color: style[key] ? '#00d4ff' : '#8b949e',
}}
>
{label}
</button>
))}
</div>
</div>
</div>
<Button
@@ -38,6 +38,8 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
const [customColor, setCustomColor] = useState<string | undefined>(initial?.custom_color)
const [pathStyle, setPathStyle] = useState<EdgePathStyle>(initial?.path_style ?? 'bezier')
const [animation, setAnimation] = useState<AnimMode>(() => toAnimMode(initial?.animated))
const [markerStart, setMarkerStart] = useState<boolean>(initial?.marker_start ?? false)
const [markerEnd, setMarkerEnd] = useState<boolean>(initial?.marker_end ?? false)
const effectiveColor = customColor ?? EDGE_DEFAULT_COLORS[type]
@@ -50,6 +52,8 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
custom_color: customColor,
path_style: pathStyle,
animated: animation !== 'none' ? animation : undefined,
marker_start: markerStart,
marker_end: markerEnd,
})
onClose()
}
@@ -153,6 +157,30 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Arrows</Label>
<div className={`flex rounded-md overflow-hidden border border-[#30363d] ${modalStyles['modal-interactive']}`}>
{([['Start', markerStart, setMarkerStart], ['End', markerEnd, setMarkerEnd]] as [string, boolean, (v: boolean) => void][]).map(([label, active, set], i) => (
<button
key={label}
type="button"
onClick={() => set(!active)}
className="flex-1 py-1 text-xs capitalize transition-colors cursor-pointer"
tabIndex={0}
aria-label={`Arrow ${label} ${active ? 'on' : 'off'}`}
aria-pressed={active}
style={{
background: active ? '#00d4ff22' : '#21262d',
color: active ? '#00d4ff' : '#8b949e',
borderRight: i === 0 ? '1px solid #30363d' : undefined,
}}
>
{label}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">Color</Label>
@@ -124,6 +124,28 @@ describe('CustomStyleModal', () => {
expect(markUnsaved).not.toHaveBeenCalled()
})
it('edge editor exposes Start/End arrow toggles defaulting off', () => {
render(<CustomStyleModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
fireEvent.click(screen.getByRole('button', { name: /Ethernet/ }))
const startBtn = screen.getByRole('button', { name: 'Start' })
const endBtn = screen.getByRole('button', { name: 'End' })
expect(startBtn.getAttribute('aria-pressed')).toBe('false')
expect(endBtn.getAttribute('aria-pressed')).toBe('false')
})
it('toggling End arrow feeds arrowEnd to applyTypeEdgeStyle', () => {
const applyTypeEdgeStyle = vi.fn()
useCanvasStore.setState({ applyTypeEdgeStyle })
render(<CustomStyleModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
fireEvent.click(screen.getByRole('button', { name: /Ethernet/ }))
fireEvent.click(screen.getByRole('button', { name: 'End' }))
fireEvent.click(screen.getByRole('button', { name: /Apply to existing Ethernet/ }))
expect(applyTypeEdgeStyle.mock.calls[0][1].arrowEnd).toBe(true)
expect(applyTypeEdgeStyle.mock.calls[0][1].arrowStart).toBe(false)
})
it('editing path style updates the edge draft', () => {
render(<CustomStyleModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
@@ -165,6 +165,41 @@ describe('EdgeModal', () => {
expect(onSubmit.mock.calls[0][0].animated).toBe('basic')
})
// ── Arrow markers ─────────────────────────────────────────────────────────
it('arrows default to off', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].marker_start).toBe(false)
expect(onSubmit.mock.calls[0][0].marker_end).toBe(false)
})
it('toggling End arrow sends marker_end: true', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByRole('button', { name: /Arrow End/ }))
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].marker_end).toBe(true)
expect(onSubmit.mock.calls[0][0].marker_start).toBe(false)
})
it('toggling Start arrow sends marker_start: true', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByRole('button', { name: /Arrow Start/ }))
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].marker_start).toBe(true)
})
it('pre-fills arrows from initial', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ marker_start: true, marker_end: true }} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].marker_start).toBe(true)
expect(onSubmit.mock.calls[0][0].marker_end).toBe(true)
})
it('selecting None after Snake omits animated from payload', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)