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
+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