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
+4
View File
@@ -81,6 +81,10 @@ async def init_db() -> None:
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN target_handle TEXT")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN animated BOOLEAN NOT NULL DEFAULT 0")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN marker_start BOOLEAN NOT NULL DEFAULT 0")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN marker_end BOOLEAN NOT NULL DEFAULT 0")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_count INTEGER")
with suppress(OperationalError):
+2
View File
@@ -86,6 +86,8 @@ class Edge(Base):
custom_color: Mapped[str | None] = mapped_column(String)
path_style: Mapped[str | None] = mapped_column(String)
animated: Mapped[str] = mapped_column(String, nullable=False, default='none')
marker_start: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
marker_end: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
source_handle: Mapped[str | None] = mapped_column(String)
target_handle: Mapped[str | None] = mapped_column(String)
waypoints: Mapped[list[dict[str, float]] | None] = mapped_column(JSON, nullable=True)
+2
View File
@@ -52,6 +52,8 @@ class EdgeSave(BaseModel):
custom_color: str | None = None
path_style: str | None = None
animated: str = 'none'
marker_start: bool = False
marker_end: bool = False
source_handle: str | None = None
target_handle: str | None = None
waypoints: list[dict[str, float]] | None = None
+4
View File
@@ -15,6 +15,8 @@ class EdgeBase(BaseModel):
custom_color: str | None = None
path_style: str | None = None
animated: str = 'none'
marker_start: bool = False
marker_end: bool = False
source_handle: str | None = None
target_handle: str | None = None
waypoints: list[dict[str, float]] | None = None
@@ -37,6 +39,8 @@ class EdgeUpdate(BaseModel):
custom_color: str | None = None
path_style: str | None = None
animated: str | None = None
marker_start: bool | None = None
marker_end: bool | None = None
source_handle: str | None = None
target_handle: str | None = None
waypoints: list[dict[str, float]] | None = None