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
+18 -7
View File
@@ -51,26 +51,37 @@ async def test_save_canvas_creates_nodes_and_edges(client: AsyncClient, headers:
assert canvas["viewport"] == {"x": 1, "y": 2, "zoom": 1.5}
async def test_save_canvas_round_trips_arrow_markers(client: AsyncClient, headers: dict):
async def test_save_canvas_round_trips_marker_shapes(client: AsyncClient, headers: dict):
n1 = node_payload(label="Router", type="router")
n2 = node_payload(label="Switch", type="switch")
e1 = edge_payload(n1["id"], n2["id"], marker_start=True, marker_end=True)
e1 = edge_payload(n1["id"], n2["id"], marker_start="diamond", marker_end="arrow")
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
edge = (await client.get("/api/v1/canvas", headers=headers)).json()["edges"][0]
assert edge["marker_start"] is True
assert edge["marker_end"] is True
assert edge["marker_start"] == "diamond"
assert edge["marker_end"] == "arrow"
async def test_save_canvas_defaults_arrow_markers_off(client: AsyncClient, headers: dict):
async def test_save_canvas_coerces_legacy_boolean_marker(client: AsyncClient, headers: dict):
n1 = node_payload(label="Router", type="router")
n2 = node_payload(label="Switch", type="switch")
e1 = edge_payload(n1["id"], n2["id"], marker_start=True, marker_end=False)
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
edge = (await client.get("/api/v1/canvas", headers=headers)).json()["edges"][0]
assert edge["marker_start"] == "arrow"
assert edge["marker_end"] == "none"
async def test_save_canvas_defaults_markers_none(client: AsyncClient, headers: dict):
n1 = node_payload(label="Router", type="router")
n2 = node_payload(label="Switch", type="switch")
e1 = edge_payload(n1["id"], n2["id"])
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
edge = (await client.get("/api/v1/canvas", headers=headers)).json()["edges"][0]
assert edge["marker_start"] is False
assert edge["marker_end"] is False
assert edge["marker_start"] == "none"
assert edge["marker_end"] == "none"
async def test_save_canvas_round_trips_per_side_handles(client: AsyncClient, headers: dict):
+25 -11
View File
@@ -91,29 +91,43 @@ async def test_update_edge_custom_color_and_path_style(client: AsyncClient, head
assert res.json()["path_style"] == "smooth"
async def test_create_edge_with_arrow_markers(client: AsyncClient, headers: dict, two_nodes):
async def test_create_edge_with_marker_shapes(client: AsyncClient, headers: dict, two_nodes):
src, tgt = two_nodes
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet", "marker_start": True, "marker_end": True}, headers=headers)
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet", "marker_start": "diamond", "marker_end": "arrow"}, headers=headers)
assert res.status_code == 201
assert res.json()["marker_start"] is True
assert res.json()["marker_end"] is True
assert res.json()["marker_start"] == "diamond"
assert res.json()["marker_end"] == "arrow"
async def test_create_edge_defaults_arrow_markers_off(client: AsyncClient, headers: dict, two_nodes):
async def test_create_edge_defaults_markers_none(client: AsyncClient, headers: dict, two_nodes):
src, tgt = two_nodes
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers)
assert res.status_code == 201
assert res.json()["marker_start"] is False
assert res.json()["marker_end"] is False
assert res.json()["marker_start"] == "none"
assert res.json()["marker_end"] == "none"
async def test_update_edge_arrow_markers(client: AsyncClient, headers: dict, two_nodes):
async def test_create_edge_coerces_legacy_boolean_marker(client: AsyncClient, headers: dict, two_nodes):
src, tgt = two_nodes
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet", "marker_end": True}, headers=headers)
assert res.status_code == 201
assert res.json()["marker_end"] == "arrow"
async def test_create_edge_rejects_unknown_marker_shape(client: AsyncClient, headers: dict, two_nodes):
src, tgt = two_nodes
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet", "marker_end": "bogus"}, headers=headers)
assert res.status_code == 201
assert res.json()["marker_end"] == "none"
async def test_update_edge_marker_shape(client: AsyncClient, headers: dict, two_nodes):
src, tgt = two_nodes
edge_id = (await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers)).json()["id"]
res = await client.patch(f"/api/v1/edges/{edge_id}", json={"marker_end": True}, headers=headers)
res = await client.patch(f"/api/v1/edges/{edge_id}", json={"marker_end": "circle"}, headers=headers)
assert res.status_code == 200
assert res.json()["marker_end"] is True
assert res.json()["marker_start"] is False
assert res.json()["marker_end"] == "circle"
assert res.json()["marker_start"] == "none"
async def test_create_edge_requires_auth(client: AsyncClient, two_nodes):