feat: selectable marker shapes per edge endpoint

Replace the on/off arrowhead toggle with a per-end shape picker. Each end
(start/end) independently selects: none, arrow, arrow-open, circle, diamond,
or square. Markers still recolor live from the resolved stroke color.

Frontend:
- MarkerShape type + edgeMarkers util (normalizeMarker, MARKER_GEOMETRY);
  legacy boolean coerces to 'arrow' on read.
- Per-shape <marker> inner geometry; symmetric shapes use fixed orient.
- MarkerShapePicker reused in EdgeModal and CustomStyleModal.
- Serializer normalizes to shape strings.

Backend:
- Edge marker columns Boolean -> String (default 'none'); TEXT migration.
- normalize_marker() + validators coerce legacy bool / unknown values.

ha-relevant: yes
This commit is contained in:
Pouzor
2026-07-05 11:30:49 +02:00
parent 1cf525844b
commit c95d104245
20 changed files with 385 additions and 157 deletions
+2 -2
View File
@@ -82,9 +82,9 @@ async def init_db() -> None:
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")
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN marker_start TEXT NOT NULL DEFAULT 'none'")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN marker_end BOOLEAN NOT NULL DEFAULT 0")
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN marker_end TEXT NOT NULL DEFAULT 'none'")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_count INTEGER")
with suppress(OperationalError):
+2 -2
View File
@@ -86,8 +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)
marker_start: Mapped[str] = mapped_column(String, nullable=False, default='none')
marker_end: Mapped[str] = mapped_column(String, nullable=False, default='none')
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)
+8 -3
View File
@@ -4,7 +4,7 @@ from pydantic import BaseModel, field_validator
from app.schemas.edges import EdgeResponse
from app.schemas.nodes import NodeResponse
from app.schemas.utils import normalize_animated
from app.schemas.utils import normalize_animated, normalize_marker
class NodeSave(BaseModel):
@@ -52,8 +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
marker_start: str = 'none'
marker_end: str = 'none'
source_handle: str | None = None
target_handle: str | None = None
waypoints: list[dict[str, float]] | None = None
@@ -63,6 +63,11 @@ class EdgeSave(BaseModel):
def validate_animated(cls, v: object) -> str:
return normalize_animated(v)
@field_validator('marker_start', 'marker_end', mode='before')
@classmethod
def validate_marker(cls, v: object) -> str:
return normalize_marker(v)
class CanvasSaveRequest(BaseModel):
nodes: list[NodeSave] = []
+17 -5
View File
@@ -2,7 +2,7 @@ from datetime import datetime
from pydantic import BaseModel, field_validator
from app.schemas.utils import normalize_animated
from app.schemas.utils import normalize_animated, normalize_marker
class EdgeBase(BaseModel):
@@ -15,8 +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
marker_start: str = 'none'
marker_end: str = 'none'
source_handle: str | None = None
target_handle: str | None = None
waypoints: list[dict[str, float]] | None = None
@@ -26,6 +26,11 @@ class EdgeBase(BaseModel):
def validate_animated(cls, v: object) -> str:
return normalize_animated(v)
@field_validator('marker_start', 'marker_end', mode='before')
@classmethod
def validate_marker(cls, v: object) -> str:
return normalize_marker(v)
class EdgeCreate(EdgeBase):
design_id: str | None = None
@@ -39,8 +44,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
marker_start: str | None = None
marker_end: str | None = None
source_handle: str | None = None
target_handle: str | None = None
waypoints: list[dict[str, float]] | None = None
@@ -52,6 +57,13 @@ class EdgeUpdate(BaseModel):
return None
return normalize_animated(v)
@field_validator('marker_start', 'marker_end', mode='before')
@classmethod
def validate_marker(cls, v: object) -> str | None:
if v is None:
return None
return normalize_marker(v)
class EdgeResponse(EdgeBase):
id: str
+18
View File
@@ -7,3 +7,21 @@ def normalize_animated(v: object) -> str:
if v in ('snake', 'flow', 'basic'):
return str(v)
return 'none'
MARKER_SHAPES = {'none', 'arrow', 'arrow-open', 'circle', 'diamond', 'square'}
def normalize_marker(v: object) -> str:
"""Normalize an edge endpoint marker to a shape string.
Legacy saves stored a boolean (True = filled arrow); coerce those and any
unknown value to a valid MarkerShape ('none' when off/unknown).
"""
if v is True or v == 1 or v == '1':
return 'arrow'
if v is False or v == 0 or v == '0' or v is None:
return 'none'
if isinstance(v, str) and v in MARKER_SHAPES:
return v
return 'none'