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
@@ -243,6 +243,19 @@ describe('serializeEdge', () => {
expect(result.animated).toBe(true)
})
it('serializes arrow markers', () => {
const edge = makeRfEdge({ data: { type: 'ethernet', marker_start: true, marker_end: true } })
const result = serializeEdge(edge)
expect(result.marker_start).toBe(true)
expect(result.marker_end).toBe(true)
})
it('defaults arrow markers to false when absent', () => {
const result = serializeEdge(makeRfEdge())
expect(result.marker_start).toBe(false)
expect(result.marker_end).toBe(false)
})
it('nulls optional fields when absent', () => {
const result = serializeEdge(makeRfEdge({ sourceHandle: undefined, targetHandle: undefined }))
expect(result.source_handle).toBeNull()
@@ -182,6 +182,17 @@ describe('rewireEdgesForCollapse', () => {
expect(out[0]).toBe(edges[0])
})
it('keeps multiple parallel edges between two visible nodes (no dedupe)', () => {
// Regression: parallel links between the same two visible devices must all
// render. The seen-key dedupe applies only to rewired collapse stubs.
const nodes = [mkNode('a'), mkNode('b', { position: FAR })]
const edges = [mkEdge('e1', 'a', 'b'), mkEdge('e2', 'a', 'b'), mkEdge('e3', 'a', 'b')]
const info = computeCollapseInfo(nodes)
const out = rewireEdgesForCollapse(edges, nodes, info.visibleIds, info.hiddenBy)
expect(out).toHaveLength(3)
expect(out.map((e) => e.id)).toEqual(['e1', 'e2', 'e3'])
})
it('reroutes a cross-boundary edge to the collapsed parentId ancestor', () => {
const nodes = [
mkNode('zone', { collapsed: true }),