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:
@@ -82,9 +82,9 @@ async def init_db() -> None:
|
|||||||
with suppress(OperationalError):
|
with suppress(OperationalError):
|
||||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN animated BOOLEAN NOT NULL DEFAULT 0")
|
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN animated BOOLEAN NOT NULL DEFAULT 0")
|
||||||
with suppress(OperationalError):
|
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):
|
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):
|
with suppress(OperationalError):
|
||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_count INTEGER")
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_count INTEGER")
|
||||||
with suppress(OperationalError):
|
with suppress(OperationalError):
|
||||||
|
|||||||
@@ -86,8 +86,8 @@ class Edge(Base):
|
|||||||
custom_color: Mapped[str | None] = mapped_column(String)
|
custom_color: Mapped[str | None] = mapped_column(String)
|
||||||
path_style: Mapped[str | None] = mapped_column(String)
|
path_style: Mapped[str | None] = mapped_column(String)
|
||||||
animated: Mapped[str] = mapped_column(String, nullable=False, default='none')
|
animated: Mapped[str] = mapped_column(String, nullable=False, default='none')
|
||||||
marker_start: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
marker_start: Mapped[str] = mapped_column(String, nullable=False, default='none')
|
||||||
marker_end: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
marker_end: Mapped[str] = mapped_column(String, nullable=False, default='none')
|
||||||
source_handle: Mapped[str | None] = mapped_column(String)
|
source_handle: Mapped[str | None] = mapped_column(String)
|
||||||
target_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)
|
waypoints: Mapped[list[dict[str, float]] | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from pydantic import BaseModel, field_validator
|
|||||||
|
|
||||||
from app.schemas.edges import EdgeResponse
|
from app.schemas.edges import EdgeResponse
|
||||||
from app.schemas.nodes import NodeResponse
|
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):
|
class NodeSave(BaseModel):
|
||||||
@@ -52,8 +52,8 @@ class EdgeSave(BaseModel):
|
|||||||
custom_color: str | None = None
|
custom_color: str | None = None
|
||||||
path_style: str | None = None
|
path_style: str | None = None
|
||||||
animated: str = 'none'
|
animated: str = 'none'
|
||||||
marker_start: bool = False
|
marker_start: str = 'none'
|
||||||
marker_end: bool = False
|
marker_end: str = 'none'
|
||||||
source_handle: str | None = None
|
source_handle: str | None = None
|
||||||
target_handle: str | None = None
|
target_handle: str | None = None
|
||||||
waypoints: list[dict[str, float]] | None = None
|
waypoints: list[dict[str, float]] | None = None
|
||||||
@@ -63,6 +63,11 @@ class EdgeSave(BaseModel):
|
|||||||
def validate_animated(cls, v: object) -> str:
|
def validate_animated(cls, v: object) -> str:
|
||||||
return normalize_animated(v)
|
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):
|
class CanvasSaveRequest(BaseModel):
|
||||||
nodes: list[NodeSave] = []
|
nodes: list[NodeSave] = []
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
from pydantic import BaseModel, field_validator
|
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):
|
class EdgeBase(BaseModel):
|
||||||
@@ -15,8 +15,8 @@ class EdgeBase(BaseModel):
|
|||||||
custom_color: str | None = None
|
custom_color: str | None = None
|
||||||
path_style: str | None = None
|
path_style: str | None = None
|
||||||
animated: str = 'none'
|
animated: str = 'none'
|
||||||
marker_start: bool = False
|
marker_start: str = 'none'
|
||||||
marker_end: bool = False
|
marker_end: str = 'none'
|
||||||
source_handle: str | None = None
|
source_handle: str | None = None
|
||||||
target_handle: str | None = None
|
target_handle: str | None = None
|
||||||
waypoints: list[dict[str, float]] | None = None
|
waypoints: list[dict[str, float]] | None = None
|
||||||
@@ -26,6 +26,11 @@ class EdgeBase(BaseModel):
|
|||||||
def validate_animated(cls, v: object) -> str:
|
def validate_animated(cls, v: object) -> str:
|
||||||
return normalize_animated(v)
|
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):
|
class EdgeCreate(EdgeBase):
|
||||||
design_id: str | None = None
|
design_id: str | None = None
|
||||||
@@ -39,8 +44,8 @@ class EdgeUpdate(BaseModel):
|
|||||||
custom_color: str | None = None
|
custom_color: str | None = None
|
||||||
path_style: str | None = None
|
path_style: str | None = None
|
||||||
animated: str | None = None
|
animated: str | None = None
|
||||||
marker_start: bool | None = None
|
marker_start: str | None = None
|
||||||
marker_end: bool | None = None
|
marker_end: str | None = None
|
||||||
source_handle: str | None = None
|
source_handle: str | None = None
|
||||||
target_handle: str | None = None
|
target_handle: str | None = None
|
||||||
waypoints: list[dict[str, float]] | None = None
|
waypoints: list[dict[str, float]] | None = None
|
||||||
@@ -52,6 +57,13 @@ class EdgeUpdate(BaseModel):
|
|||||||
return None
|
return None
|
||||||
return normalize_animated(v)
|
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):
|
class EdgeResponse(EdgeBase):
|
||||||
id: str
|
id: str
|
||||||
|
|||||||
@@ -7,3 +7,21 @@ def normalize_animated(v: object) -> str:
|
|||||||
if v in ('snake', 'flow', 'basic'):
|
if v in ('snake', 'flow', 'basic'):
|
||||||
return str(v)
|
return str(v)
|
||||||
return 'none'
|
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'
|
||||||
|
|||||||
@@ -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}
|
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")
|
n1 = node_payload(label="Router", type="router")
|
||||||
n2 = node_payload(label="Switch", type="switch")
|
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)
|
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]
|
edge = (await client.get("/api/v1/canvas", headers=headers)).json()["edges"][0]
|
||||||
assert edge["marker_start"] is True
|
assert edge["marker_start"] == "diamond"
|
||||||
assert edge["marker_end"] is True
|
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")
|
n1 = node_payload(label="Router", type="router")
|
||||||
n2 = node_payload(label="Switch", type="switch")
|
n2 = node_payload(label="Switch", type="switch")
|
||||||
e1 = edge_payload(n1["id"], n2["id"])
|
e1 = edge_payload(n1["id"], n2["id"])
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
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]
|
edge = (await client.get("/api/v1/canvas", headers=headers)).json()["edges"][0]
|
||||||
assert edge["marker_start"] is False
|
assert edge["marker_start"] == "none"
|
||||||
assert edge["marker_end"] is False
|
assert edge["marker_end"] == "none"
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_round_trips_per_side_handles(client: AsyncClient, headers: dict):
|
async def test_save_canvas_round_trips_per_side_handles(client: AsyncClient, headers: dict):
|
||||||
|
|||||||
+25
-11
@@ -91,29 +91,43 @@ async def test_update_edge_custom_color_and_path_style(client: AsyncClient, head
|
|||||||
assert res.json()["path_style"] == "smooth"
|
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
|
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.status_code == 201
|
||||||
assert res.json()["marker_start"] is True
|
assert res.json()["marker_start"] == "diamond"
|
||||||
assert res.json()["marker_end"] is True
|
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
|
src, tgt = two_nodes
|
||||||
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers)
|
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers)
|
||||||
assert res.status_code == 201
|
assert res.status_code == 201
|
||||||
assert res.json()["marker_start"] is False
|
assert res.json()["marker_start"] == "none"
|
||||||
assert res.json()["marker_end"] is False
|
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
|
src, tgt = two_nodes
|
||||||
edge_id = (await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers)).json()["id"]
|
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.status_code == 200
|
||||||
assert res.json()["marker_end"] is True
|
assert res.json()["marker_end"] == "circle"
|
||||||
assert res.json()["marker_start"] is False
|
assert res.json()["marker_start"] == "none"
|
||||||
|
|
||||||
|
|
||||||
async def test_create_edge_requires_auth(client: AsyncClient, two_nodes):
|
async def test_create_edge_requires_auth(client: AsyncClient, two_nodes):
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import { HomelableEdge } from '../index'
|
|||||||
import type { EdgeData } from '@/types'
|
import type { EdgeData } from '@/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Arrowhead endpoints: filled-triangle <marker> defs, independently toggleable
|
* Endpoint markers: per-end shape (arrow / arrow-open / circle / diamond /
|
||||||
* at start/end, filled with the live stroke color, and referenced by BaseEdge
|
* square) <marker> defs, independently selectable at start/end, filled with the
|
||||||
* via markerStart/markerEnd URLs.
|
* live stroke color, referenced by BaseEdge via markerStart/markerEnd URLs.
|
||||||
|
* Legacy boolean values coerce to the filled 'arrow' shape.
|
||||||
*/
|
*/
|
||||||
function renderEdge(data: Partial<EdgeData> = {}, selected = false) {
|
function renderEdge(data: Partial<EdgeData> = {}, selected = false) {
|
||||||
const props = {
|
const props = {
|
||||||
@@ -41,7 +42,7 @@ describe('HomelableEdge arrow markers', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('renders an end marker referenced by the edge path', () => {
|
it('renders an end marker referenced by the edge path', () => {
|
||||||
const { container } = renderEdge({ marker_end: true })
|
const { container } = renderEdge({ marker_end: 'arrow' })
|
||||||
const marker = container.querySelector('#arrow-end-e1')
|
const marker = container.querySelector('#arrow-end-e1')
|
||||||
expect(marker).toBeTruthy()
|
expect(marker).toBeTruthy()
|
||||||
expect(container.querySelector('#arrow-start-e1')).toBeNull()
|
expect(container.querySelector('#arrow-start-e1')).toBeNull()
|
||||||
@@ -51,21 +52,58 @@ describe('HomelableEdge arrow markers', () => {
|
|||||||
expect(referenced).toBe(true)
|
expect(referenced).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders a start marker with reversed orientation', () => {
|
it('coerces a legacy boolean marker to the filled arrow shape', () => {
|
||||||
const { container } = renderEdge({ marker_start: true })
|
const { container } = renderEdge({ marker_end: true })
|
||||||
|
const path = container.querySelector('#arrow-end-e1 path')
|
||||||
|
expect(path?.getAttribute('d')).toBe('M 0 0 L 10 5 L 0 10 z')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a directional start marker with reversed orientation', () => {
|
||||||
|
const { container } = renderEdge({ marker_start: 'arrow' })
|
||||||
const marker = container.querySelector('#arrow-start-e1')
|
const marker = container.querySelector('#arrow-start-e1')
|
||||||
expect(marker).toBeTruthy()
|
expect(marker).toBeTruthy()
|
||||||
expect(marker?.getAttribute('orient')).toBe('auto-start-reverse')
|
expect(marker?.getAttribute('orient')).toBe('auto-start-reverse')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders a circle marker as a <circle>, not a triangle', () => {
|
||||||
|
const { container } = renderEdge({ marker_end: 'circle' })
|
||||||
|
expect(container.querySelector('#arrow-end-e1 circle')).toBeTruthy()
|
||||||
|
expect(container.querySelector('#arrow-end-e1 path')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a square marker as a <rect>', () => {
|
||||||
|
const { container } = renderEdge({ marker_end: 'square' })
|
||||||
|
expect(container.querySelector('#arrow-end-e1 rect')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses fixed orientation for symmetric shapes', () => {
|
||||||
|
const { container } = renderEdge({ marker_end: 'circle' })
|
||||||
|
expect(container.querySelector('#arrow-end-e1')?.getAttribute('orient')).toBe('0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports different shapes on each end', () => {
|
||||||
|
const { container } = renderEdge({ marker_start: 'diamond', marker_end: 'arrow-open' })
|
||||||
|
// diamond is a filled path
|
||||||
|
const startPath = container.querySelector('#arrow-start-e1 path')
|
||||||
|
expect(startPath?.getAttribute('d')).toContain('9.5')
|
||||||
|
expect(startPath?.getAttribute('fill')).not.toBe('none')
|
||||||
|
// arrow-open is stroked, not filled
|
||||||
|
expect(container.querySelector('#arrow-end-e1 path')?.getAttribute('fill')).toBe('none')
|
||||||
|
})
|
||||||
|
|
||||||
it('renders both markers when both ends enabled', () => {
|
it('renders both markers when both ends enabled', () => {
|
||||||
const { container } = renderEdge({ marker_start: true, marker_end: true })
|
const { container } = renderEdge({ marker_start: 'arrow', marker_end: 'arrow' })
|
||||||
expect(container.querySelector('#arrow-start-e1')).toBeTruthy()
|
expect(container.querySelector('#arrow-start-e1')).toBeTruthy()
|
||||||
expect(container.querySelector('#arrow-end-e1')).toBeTruthy()
|
expect(container.querySelector('#arrow-end-e1')).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders no marker for the "none" shape', () => {
|
||||||
|
const { container } = renderEdge({ marker_start: 'none', marker_end: 'none' })
|
||||||
|
expect(container.querySelector('marker')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it('fills the marker with the resolved custom color', () => {
|
it('fills the marker with the resolved custom color', () => {
|
||||||
const { container } = renderEdge({ marker_end: true, custom_color: '#ff6e00' })
|
const { container } = renderEdge({ marker_end: 'arrow', custom_color: '#ff6e00' })
|
||||||
const fill = container.querySelector('#arrow-end-e1 path')?.getAttribute('fill')
|
const fill = container.querySelector('#arrow-end-e1 path')?.getAttribute('fill')
|
||||||
expect(fill).toBe('#ff6e00')
|
expect(fill).toBe('#ff6e00')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { EdgeData, EdgeType, Waypoint } from '@/types'
|
|||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { THEMES } from '@/utils/themes'
|
import { THEMES } from '@/utils/themes'
|
||||||
|
import { MARKER_GEOMETRY, normalizeMarker, type NonNoneMarkerShape } from '@/utils/edgeMarkers'
|
||||||
import { buildWaypointPath, getAddWaypointHandlePosition, getWaypointLabelPosition, snap45, snap45both } from './waypointUtils'
|
import { buildWaypointPath, getAddWaypointHandlePosition, getWaypointLabelPosition, snap45, snap45both } from './waypointUtils'
|
||||||
|
|
||||||
const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149']
|
const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149']
|
||||||
@@ -22,6 +23,22 @@ function getVlanColor(vlanId?: number): string {
|
|||||||
return VLAN_COLORS[vlanId % VLAN_COLORS.length]
|
return VLAN_COLORS[vlanId % VLAN_COLORS.length]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Inner SVG element for an edge <marker>, drawn in a 0..10 viewBox. */
|
||||||
|
function markerInnerElement(shape: NonNoneMarkerShape, color: string): React.ReactElement {
|
||||||
|
switch (shape) {
|
||||||
|
case 'arrow':
|
||||||
|
return <path d="M 0 0 L 10 5 L 0 10 z" fill={color} />
|
||||||
|
case 'arrow-open':
|
||||||
|
return <path d="M 1 1 L 9 5 L 1 9" fill="none" stroke={color} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
case 'circle':
|
||||||
|
return <circle cx={5} cy={5} r={4} fill={color} />
|
||||||
|
case 'diamond':
|
||||||
|
return <path d="M 5 0.5 L 9.5 5 L 5 9.5 L 0.5 5 z" fill={color} />
|
||||||
|
case 'square':
|
||||||
|
return <rect x={1} y={1} width={8} height={8} fill={color} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Waypoint drag handle ─────────────────────────────────────────────────────
|
// ── Waypoint drag handle ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface WaypointHandleProps {
|
interface WaypointHandleProps {
|
||||||
@@ -351,37 +368,43 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle
|
|||||||
? segmentMidpoints(sourceX, sourceY, waypoints, targetX, targetY, pathStyle, sourcePosition)
|
? segmentMidpoints(sourceX, sourceY, waypoints, targetX, targetY, pathStyle, sourcePosition)
|
||||||
: []
|
: []
|
||||||
|
|
||||||
// ── Arrowheads ─────────────────────────────────────────────────────────────
|
// ── Endpoint markers ───────────────────────────────────────────────────────
|
||||||
// Custom inline <marker> defs filled with the live strokeColor so they recolor
|
// Custom inline <marker> defs filled with the live strokeColor so they recolor
|
||||||
// reactively (custom_color / vlan / selected). Sized from the stroke width.
|
// reactively (custom_color / vlan / selected). Sized from the stroke width.
|
||||||
const markerStart = data?.marker_start === true
|
// Each end picks its own shape (arrow / arrow-open / circle / diamond / square)
|
||||||
const markerEnd = data?.marker_end === true
|
// independently; 'none' renders no marker.
|
||||||
|
const startShape = normalizeMarker(data?.marker_start)
|
||||||
|
const endShape = normalizeMarker(data?.marker_end)
|
||||||
|
const hasMarkers = startShape !== 'none' || endShape !== 'none'
|
||||||
const strokeW = (style.strokeWidth as number) ?? 2
|
const strokeW = (style.strokeWidth as number) ?? 2
|
||||||
const markerSize = 6 + strokeW * 2
|
const markerSize = 6 + strokeW * 2
|
||||||
const startMarkerId = `arrow-start-${id}`
|
const startMarkerId = `arrow-start-${id}`
|
||||||
const endMarkerId = `arrow-end-${id}`
|
const endMarkerId = `arrow-end-${id}`
|
||||||
|
|
||||||
const arrowMarker = (markerId: string, orient: string) => (
|
const arrowMarker = (markerId: string, shape: NonNoneMarkerShape, orient: string) => {
|
||||||
<marker
|
const geo = MARKER_GEOMETRY[shape]
|
||||||
id={markerId}
|
return (
|
||||||
viewBox="0 0 10 10"
|
<marker
|
||||||
refX={9}
|
id={markerId}
|
||||||
refY={5}
|
viewBox="0 0 10 10"
|
||||||
markerWidth={markerSize}
|
refX={geo.refX}
|
||||||
markerHeight={markerSize}
|
refY={5}
|
||||||
markerUnits="userSpaceOnUse"
|
markerWidth={markerSize}
|
||||||
orient={orient}
|
markerHeight={markerSize}
|
||||||
>
|
markerUnits="userSpaceOnUse"
|
||||||
<path d="M 0 0 L 10 5 L 0 10 z" fill={strokeColor} />
|
orient={geo.directional ? orient : '0'}
|
||||||
</marker>
|
>
|
||||||
)
|
{markerInnerElement(shape, strokeColor)}
|
||||||
|
</marker>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{(markerStart || markerEnd) && (
|
{hasMarkers && (
|
||||||
<defs>
|
<defs>
|
||||||
{markerStart && arrowMarker(startMarkerId, 'auto-start-reverse')}
|
{startShape !== 'none' && arrowMarker(startMarkerId, startShape, 'auto-start-reverse')}
|
||||||
{markerEnd && arrowMarker(endMarkerId, 'auto')}
|
{endShape !== 'none' && arrowMarker(endMarkerId, endShape, 'auto')}
|
||||||
</defs>
|
</defs>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -390,8 +413,8 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle
|
|||||||
path={edgePath}
|
path={edgePath}
|
||||||
style={animMode === 'basic' ? { ...style, stroke: 'transparent' } : style}
|
style={animMode === 'basic' ? { ...style, stroke: 'transparent' } : style}
|
||||||
interactionWidth={16}
|
interactionWidth={16}
|
||||||
markerStart={markerStart ? `url(#${startMarkerId})` : undefined}
|
markerStart={startShape !== 'none' ? `url(#${startMarkerId})` : undefined}
|
||||||
markerEnd={markerEnd ? `url(#${endMarkerId})` : undefined}
|
markerEnd={endShape !== 'none' ? `url(#${endMarkerId})` : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{animMode === 'basic' && (
|
{animMode === 'basic' && (
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, EdgePathStyle,
|
NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, EdgePathStyle,
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
import { NODE_TYPE_LABELS, EDGE_TYPE_LABELS } from '@/types'
|
import { NODE_TYPE_LABELS, EDGE_TYPE_LABELS } from '@/types'
|
||||||
|
import { MarkerShapePicker } from './MarkerShapePicker'
|
||||||
|
|
||||||
// ── Node types exposed for custom style, grouped by category (skip groupRect/group) ──
|
// ── Node types exposed for custom style, grouped by category (skip groupRect/group) ──
|
||||||
|
|
||||||
@@ -64,8 +65,8 @@ function defaultEdgeStyle(edgeType: EdgeType): EdgeTypeStyle {
|
|||||||
opacity: 1,
|
opacity: 1,
|
||||||
pathStyle: 'bezier',
|
pathStyle: 'bezier',
|
||||||
animated: 'none',
|
animated: 'none',
|
||||||
arrowStart: false,
|
arrowStart: 'none',
|
||||||
arrowEnd: false,
|
arrowEnd: 'none',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,24 +284,10 @@ function EdgeEditor({ edgeType, style, onChange, onApplyToExisting }: EdgeEditor
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-[#8b949e] mb-2">Arrows</div>
|
<div className="text-xs text-[#8b949e] mb-2">Endpoints</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col gap-1.5">
|
||||||
{([['Start', 'arrowStart'], ['End', 'arrowEnd']] as [string, 'arrowStart' | 'arrowEnd'][]).map(([label, key]) => (
|
<MarkerShapePicker label="Start" value={style.arrowStart} onChange={(s) => set('arrowStart', s)} />
|
||||||
<button
|
<MarkerShapePicker label="End" value={style.arrowEnd} onChange={(s) => set('arrowEnd', s)} />
|
||||||
key={key}
|
|
||||||
type="button"
|
|
||||||
onClick={() => set(key, !style[key])}
|
|
||||||
aria-pressed={style[key]}
|
|
||||||
className="px-3 py-1 text-xs rounded border transition-colors"
|
|
||||||
style={{
|
|
||||||
borderColor: style[key] ? '#00d4ff' : '#30363d',
|
|
||||||
background: style[key] ? '#00d4ff22' : 'transparent',
|
|
||||||
color: style[key] ? '#00d4ff' : '#8b949e',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { EDGE_TYPE_LABELS, type EdgeData, type EdgePathStyle, type EdgeType } from '@/types'
|
import { EDGE_TYPE_LABELS, type EdgeData, type EdgePathStyle, type EdgeType, type MarkerShape } from '@/types'
|
||||||
import { EDGE_DEFAULT_COLORS } from '@/utils/edgeColors'
|
import { EDGE_DEFAULT_COLORS } from '@/utils/edgeColors'
|
||||||
|
import { normalizeMarker } from '@/utils/edgeMarkers'
|
||||||
|
import { MarkerShapePicker } from './MarkerShapePicker'
|
||||||
|
|
||||||
const EDGE_TYPES = Object.entries(EDGE_TYPE_LABELS) as [EdgeType, string][]
|
const EDGE_TYPES = Object.entries(EDGE_TYPE_LABELS) as [EdgeType, string][]
|
||||||
|
|
||||||
@@ -38,8 +40,8 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
const [customColor, setCustomColor] = useState<string | undefined>(initial?.custom_color)
|
const [customColor, setCustomColor] = useState<string | undefined>(initial?.custom_color)
|
||||||
const [pathStyle, setPathStyle] = useState<EdgePathStyle>(initial?.path_style ?? 'bezier')
|
const [pathStyle, setPathStyle] = useState<EdgePathStyle>(initial?.path_style ?? 'bezier')
|
||||||
const [animation, setAnimation] = useState<AnimMode>(() => toAnimMode(initial?.animated))
|
const [animation, setAnimation] = useState<AnimMode>(() => toAnimMode(initial?.animated))
|
||||||
const [markerStart, setMarkerStart] = useState<boolean>(initial?.marker_start ?? false)
|
const [markerStart, setMarkerStart] = useState<MarkerShape>(normalizeMarker(initial?.marker_start))
|
||||||
const [markerEnd, setMarkerEnd] = useState<boolean>(initial?.marker_end ?? false)
|
const [markerEnd, setMarkerEnd] = useState<MarkerShape>(normalizeMarker(initial?.marker_end))
|
||||||
|
|
||||||
const effectiveColor = customColor ?? EDGE_DEFAULT_COLORS[type]
|
const effectiveColor = customColor ?? EDGE_DEFAULT_COLORS[type]
|
||||||
|
|
||||||
@@ -158,26 +160,10 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Arrows</Label>
|
<Label className="text-xs text-muted-foreground">Endpoints</Label>
|
||||||
<div className={`flex rounded-md overflow-hidden border border-[#30363d] ${modalStyles['modal-interactive']}`}>
|
<div className="flex flex-col gap-1.5">
|
||||||
{([['Start', markerStart, setMarkerStart], ['End', markerEnd, setMarkerEnd]] as [string, boolean, (v: boolean) => void][]).map(([label, active, set], i) => (
|
<MarkerShapePicker label="Start" value={markerStart} onChange={setMarkerStart} />
|
||||||
<button
|
<MarkerShapePicker label="End" value={markerEnd} onChange={setMarkerEnd} />
|
||||||
key={label}
|
|
||||||
type="button"
|
|
||||||
onClick={() => set(!active)}
|
|
||||||
className="flex-1 py-1 text-xs capitalize transition-colors cursor-pointer"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label={`Arrow ${label} ${active ? 'on' : 'off'}`}
|
|
||||||
aria-pressed={active}
|
|
||||||
style={{
|
|
||||||
background: active ? '#00d4ff22' : '#21262d',
|
|
||||||
color: active ? '#00d4ff' : '#8b949e',
|
|
||||||
borderRight: i === 0 ? '1px solid #30363d' : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { ReactElement } from 'react'
|
||||||
|
import type { MarkerShape } from '@/types'
|
||||||
|
import { MARKER_SHAPES } from '@/utils/edgeMarkers'
|
||||||
|
|
||||||
|
/** 16x16 preview glyph for a marker shape (used in the picker buttons). */
|
||||||
|
function markerGlyph(shape: MarkerShape, color: string): ReactElement {
|
||||||
|
switch (shape) {
|
||||||
|
case 'none':
|
||||||
|
return <line x1={3} y1={8} x2={13} y2={8} stroke={color} strokeWidth={1.5} strokeLinecap="round" />
|
||||||
|
case 'arrow':
|
||||||
|
return <path d="M4 4 L12 8 L4 12 z" fill={color} />
|
||||||
|
case 'arrow-open':
|
||||||
|
return <path d="M5 4 L11 8 L5 12" fill="none" stroke={color} strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
case 'circle':
|
||||||
|
return <circle cx={8} cy={8} r={4} fill={color} />
|
||||||
|
case 'diamond':
|
||||||
|
return <path d="M8 3 L13 8 L8 13 L3 8 z" fill={color} />
|
||||||
|
case 'square':
|
||||||
|
return <rect x={4} y={4} width={8} height={8} fill={color} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarkerShapePickerProps {
|
||||||
|
label: string
|
||||||
|
value: MarkerShape
|
||||||
|
onChange: (shape: MarkerShape) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A labeled row of buttons to pick the marker shape for one edge end. */
|
||||||
|
export function MarkerShapePicker({ label, value, onChange }: MarkerShapePickerProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-[#8b949e] w-10 shrink-0">{label}</span>
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{MARKER_SHAPES.map((shape) => {
|
||||||
|
const active = value === shape
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={shape}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(shape)}
|
||||||
|
aria-label={`${label} marker ${shape}`}
|
||||||
|
aria-pressed={active}
|
||||||
|
title={shape}
|
||||||
|
className="w-7 h-7 rounded border flex items-center justify-center transition-colors shrink-0"
|
||||||
|
style={{
|
||||||
|
borderColor: active ? '#00d4ff' : '#30363d',
|
||||||
|
background: active ? '#00d4ff22' : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width={16} height={16} viewBox="0 0 16 16">
|
||||||
|
{markerGlyph(shape, active ? '#00d4ff' : '#8b949e')}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -124,26 +124,26 @@ describe('CustomStyleModal', () => {
|
|||||||
expect(markUnsaved).not.toHaveBeenCalled()
|
expect(markUnsaved).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('edge editor exposes Start/End arrow toggles defaulting off', () => {
|
it('edge editor exposes Start/End marker pickers defaulting to none', () => {
|
||||||
render(<CustomStyleModal open onClose={vi.fn()} />)
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
|
||||||
fireEvent.click(screen.getByRole('button', { name: /Ethernet/ }))
|
fireEvent.click(screen.getByRole('button', { name: /Ethernet/ }))
|
||||||
const startBtn = screen.getByRole('button', { name: 'Start' })
|
const startNone = screen.getByRole('button', { name: 'Start marker none' })
|
||||||
const endBtn = screen.getByRole('button', { name: 'End' })
|
const endNone = screen.getByRole('button', { name: 'End marker none' })
|
||||||
expect(startBtn.getAttribute('aria-pressed')).toBe('false')
|
expect(startNone.getAttribute('aria-pressed')).toBe('true')
|
||||||
expect(endBtn.getAttribute('aria-pressed')).toBe('false')
|
expect(endNone.getAttribute('aria-pressed')).toBe('true')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('toggling End arrow feeds arrowEnd to applyTypeEdgeStyle', () => {
|
it('picking an End shape feeds arrowEnd to applyTypeEdgeStyle', () => {
|
||||||
const applyTypeEdgeStyle = vi.fn()
|
const applyTypeEdgeStyle = vi.fn()
|
||||||
useCanvasStore.setState({ applyTypeEdgeStyle })
|
useCanvasStore.setState({ applyTypeEdgeStyle })
|
||||||
render(<CustomStyleModal open onClose={vi.fn()} />)
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
|
||||||
fireEvent.click(screen.getByRole('button', { name: /Ethernet/ }))
|
fireEvent.click(screen.getByRole('button', { name: /Ethernet/ }))
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'End' }))
|
fireEvent.click(screen.getByRole('button', { name: 'End marker diamond' }))
|
||||||
fireEvent.click(screen.getByRole('button', { name: /Apply to existing Ethernet/ }))
|
fireEvent.click(screen.getByRole('button', { name: /Apply to existing Ethernet/ }))
|
||||||
expect(applyTypeEdgeStyle.mock.calls[0][1].arrowEnd).toBe(true)
|
expect(applyTypeEdgeStyle.mock.calls[0][1].arrowEnd).toBe('diamond')
|
||||||
expect(applyTypeEdgeStyle.mock.calls[0][1].arrowStart).toBe(false)
|
expect(applyTypeEdgeStyle.mock.calls[0][1].arrowStart).toBe('none')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('editing path style updates the edge draft', () => {
|
it('editing path style updates the edge draft', () => {
|
||||||
|
|||||||
@@ -165,39 +165,56 @@ describe('EdgeModal', () => {
|
|||||||
expect(onSubmit.mock.calls[0][0].animated).toBe('basic')
|
expect(onSubmit.mock.calls[0][0].animated).toBe('basic')
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Arrow markers ─────────────────────────────────────────────────────────
|
// ── Endpoint markers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('arrows default to off', () => {
|
it('endpoints default to none', () => {
|
||||||
const onSubmit = vi.fn()
|
const onSubmit = vi.fn()
|
||||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||||
expect(onSubmit.mock.calls[0][0].marker_start).toBe(false)
|
expect(onSubmit.mock.calls[0][0].marker_start).toBe('none')
|
||||||
expect(onSubmit.mock.calls[0][0].marker_end).toBe(false)
|
expect(onSubmit.mock.calls[0][0].marker_end).toBe('none')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('toggling End arrow sends marker_end: true', () => {
|
it('picking an End arrow sends marker_end: "arrow"', () => {
|
||||||
const onSubmit = vi.fn()
|
const onSubmit = vi.fn()
|
||||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
fireEvent.click(screen.getByRole('button', { name: /Arrow End/ }))
|
fireEvent.click(screen.getByRole('button', { name: 'End marker arrow' }))
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||||
expect(onSubmit.mock.calls[0][0].marker_end).toBe(true)
|
expect(onSubmit.mock.calls[0][0].marker_end).toBe('arrow')
|
||||||
expect(onSubmit.mock.calls[0][0].marker_start).toBe(false)
|
expect(onSubmit.mock.calls[0][0].marker_start).toBe('none')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('toggling Start arrow sends marker_start: true', () => {
|
it('picking a Start circle sends marker_start: "circle"', () => {
|
||||||
const onSubmit = vi.fn()
|
const onSubmit = vi.fn()
|
||||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
fireEvent.click(screen.getByRole('button', { name: /Arrow Start/ }))
|
fireEvent.click(screen.getByRole('button', { name: 'Start marker circle' }))
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||||
expect(onSubmit.mock.calls[0][0].marker_start).toBe(true)
|
expect(onSubmit.mock.calls[0][0].marker_start).toBe('circle')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('pre-fills arrows from initial', () => {
|
it('allows a different shape on each end', () => {
|
||||||
const onSubmit = vi.fn()
|
const onSubmit = vi.fn()
|
||||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ marker_start: true, marker_end: true }} />)
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Start marker diamond' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'End marker square' }))
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||||
expect(onSubmit.mock.calls[0][0].marker_start).toBe(true)
|
expect(onSubmit.mock.calls[0][0].marker_start).toBe('diamond')
|
||||||
expect(onSubmit.mock.calls[0][0].marker_end).toBe(true)
|
expect(onSubmit.mock.calls[0][0].marker_end).toBe('square')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pre-fills endpoint shapes from initial', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ marker_start: 'diamond', marker_end: 'arrow-open' }} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||||
|
expect(onSubmit.mock.calls[0][0].marker_start).toBe('diamond')
|
||||||
|
expect(onSubmit.mock.calls[0][0].marker_end).toBe('arrow-open')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('coerces a legacy boolean initial marker to "arrow"', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ marker_end: true }} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||||
|
expect(onSubmit.mock.calls[0][0].marker_end).toBe('arrow')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('selecting None after Snake omits animated from payload', () => {
|
it('selecting None after Snake omits animated from payload', () => {
|
||||||
|
|||||||
@@ -343,12 +343,12 @@ describe('canvasStore', () => {
|
|||||||
expect(edges[0].id).not.toBe(edges[1].id)
|
expect(edges[0].id).not.toBe(edges[1].id)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('onConnect preserves arrow markers from edge data', () => {
|
it('onConnect preserves endpoint marker shapes from edge data', () => {
|
||||||
const conn = Object.assign({ source: 'n1', target: 'n2', sourceHandle: null, targetHandle: null }, { type: 'ethernet', marker_start: true, marker_end: true })
|
const conn = Object.assign({ source: 'n1', target: 'n2', sourceHandle: null, targetHandle: null }, { type: 'ethernet', marker_start: 'diamond', marker_end: 'arrow' })
|
||||||
useCanvasStore.getState().onConnect(conn)
|
useCanvasStore.getState().onConnect(conn)
|
||||||
const { edges } = useCanvasStore.getState()
|
const { edges } = useCanvasStore.getState()
|
||||||
expect(edges[0].data?.marker_start).toBe(true)
|
expect(edges[0].data?.marker_start).toBe('diamond')
|
||||||
expect(edges[0].data?.marker_end).toBe(true)
|
expect(edges[0].data?.marker_end).toBe('arrow')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('onConnect preserves sourceHandle and targetHandle for cluster edges', () => {
|
it('onConnect preserves sourceHandle and targetHandle for cluster edges', () => {
|
||||||
@@ -1320,15 +1320,15 @@ describe('canvasStore — custom style apply', () => {
|
|||||||
const e2: Edge<EdgeData> = { id: 'e2', source: 'n1', target: 'n2', type: 'wifi', data: { type: 'wifi' } }
|
const e2: Edge<EdgeData> = { id: 'e2', source: 'n1', target: 'n2', type: 'wifi', data: { type: 'wifi' } }
|
||||||
useCanvasStore.setState({ nodes: [], edges: [e1, e2] })
|
useCanvasStore.setState({ nodes: [], edges: [e1, e2] })
|
||||||
|
|
||||||
useCanvasStore.getState().applyTypeEdgeStyle('ethernet', { color: '#00ff00', opacity: 1, pathStyle: 'smooth', animated: 'flow', arrowStart: true, arrowEnd: true })
|
useCanvasStore.getState().applyTypeEdgeStyle('ethernet', { color: '#00ff00', opacity: 1, pathStyle: 'smooth', animated: 'flow', arrowStart: 'circle', arrowEnd: 'arrow' })
|
||||||
|
|
||||||
const updated1 = useCanvasStore.getState().edges.find((e) => e.id === 'e1')!
|
const updated1 = useCanvasStore.getState().edges.find((e) => e.id === 'e1')!
|
||||||
const updated2 = useCanvasStore.getState().edges.find((e) => e.id === 'e2')!
|
const updated2 = useCanvasStore.getState().edges.find((e) => e.id === 'e2')!
|
||||||
expect(updated1.data?.custom_color).toBe('#00ff00')
|
expect(updated1.data?.custom_color).toBe('#00ff00')
|
||||||
expect(updated1.data?.path_style).toBe('smooth')
|
expect(updated1.data?.path_style).toBe('smooth')
|
||||||
expect(updated1.data?.animated).toBe('flow')
|
expect(updated1.data?.animated).toBe('flow')
|
||||||
expect(updated1.data?.marker_start).toBe(true)
|
expect(updated1.data?.marker_start).toBe('circle')
|
||||||
expect(updated1.data?.marker_end).toBe(true)
|
expect(updated1.data?.marker_end).toBe('arrow')
|
||||||
expect(updated2.data?.custom_color).toBeUndefined()
|
expect(updated2.data?.custom_color).toBeUndefined()
|
||||||
expect(updated2.data?.marker_end).toBeUndefined()
|
expect(updated2.data?.marker_end).toBeUndefined()
|
||||||
})
|
})
|
||||||
@@ -1344,7 +1344,7 @@ describe('canvasStore — custom style apply', () => {
|
|||||||
proxmox: { borderColor: '#ff6e00', borderOpacity: 1, bgColor: '#111', bgOpacity: 1, iconColor: '#ff6e00', iconOpacity: 1, width: 0, height: 0 },
|
proxmox: { borderColor: '#ff6e00', borderOpacity: 1, bgColor: '#111', bgOpacity: 1, iconColor: '#ff6e00', iconOpacity: 1, width: 0, height: 0 },
|
||||||
},
|
},
|
||||||
edges: {
|
edges: {
|
||||||
ethernet: { color: '#aabbcc', opacity: 1, pathStyle: 'bezier', animated: 'none', arrowStart: false, arrowEnd: true },
|
ethernet: { color: '#aabbcc', opacity: 1, pathStyle: 'bezier', animated: 'none', arrowStart: 'none', arrowEnd: 'square' },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1354,8 +1354,8 @@ describe('canvasStore — custom style apply', () => {
|
|||||||
expect(np.data.custom_colors?.border).toBe('#ff6e00')
|
expect(np.data.custom_colors?.border).toBe('#ff6e00')
|
||||||
expect(ns.data.custom_colors?.border).toBeUndefined()
|
expect(ns.data.custom_colors?.border).toBeUndefined()
|
||||||
expect(e.data?.custom_color).toBe('#aabbcc')
|
expect(e.data?.custom_color).toBe('#aabbcc')
|
||||||
expect(e.data?.marker_end).toBe(true)
|
expect(e.data?.marker_end).toBe('square')
|
||||||
expect(e.data?.marker_start).toBe(false)
|
expect(e.data?.marker_start).toBe('none')
|
||||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ describe('themeStore', () => {
|
|||||||
it('setCustomStyle replaces the entire definition', () => {
|
it('setCustomStyle replaces the entire definition', () => {
|
||||||
const def: CustomStyleDef = {
|
const def: CustomStyleDef = {
|
||||||
nodes: { server: { borderColor: '#ff0000', borderOpacity: 1, bgColor: '#000000', bgOpacity: 1, iconColor: '#ff0000', iconOpacity: 1, width: 200, height: 80 } },
|
nodes: { server: { borderColor: '#ff0000', borderOpacity: 1, bgColor: '#000000', bgOpacity: 1, iconColor: '#ff0000', iconOpacity: 1, width: 200, height: 80 } },
|
||||||
edges: { ethernet: { color: '#00ff00', opacity: 0.8, pathStyle: 'bezier', animated: 'none', arrowStart: false, arrowEnd: false } },
|
edges: { ethernet: { color: '#00ff00', opacity: 0.8, pathStyle: 'bezier', animated: 'none', arrowStart: 'none', arrowEnd: 'none' } },
|
||||||
}
|
}
|
||||||
useThemeStore.getState().setCustomStyle(def)
|
useThemeStore.getState().setCustomStyle(def)
|
||||||
expect(useThemeStore.getState().customStyle.nodes.server?.borderColor).toBe('#ff0000')
|
expect(useThemeStore.getState().customStyle.nodes.server?.borderColor).toBe('#ff0000')
|
||||||
|
|||||||
@@ -156,6 +156,13 @@ export interface NodeData extends Record<string, unknown> {
|
|||||||
|
|
||||||
export type EdgePathStyle = 'bezier' | 'smooth'
|
export type EdgePathStyle = 'bezier' | 'smooth'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoint marker shape for an edge end. `none` = no marker.
|
||||||
|
* Legacy saves stored a boolean (`true` = filled arrow) — coerced via
|
||||||
|
* `normalizeMarker` in utils/edgeMarkers.
|
||||||
|
*/
|
||||||
|
export type MarkerShape = 'none' | 'arrow' | 'arrow-open' | 'circle' | 'diamond' | 'square'
|
||||||
|
|
||||||
export interface Waypoint {
|
export interface Waypoint {
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
@@ -169,10 +176,10 @@ export interface EdgeData extends Record<string, unknown> {
|
|||||||
custom_color?: string
|
custom_color?: string
|
||||||
path_style?: EdgePathStyle
|
path_style?: EdgePathStyle
|
||||||
animated?: boolean | 'snake' | 'flow' | 'basic' | 'none'
|
animated?: boolean | 'snake' | 'flow' | 'basic' | 'none'
|
||||||
/** Filled arrowhead at the source end. */
|
/** Marker shape at the source end. Legacy boolean (`true`=arrow) coerced on read. */
|
||||||
marker_start?: boolean
|
marker_start?: MarkerShape | boolean
|
||||||
/** Filled arrowhead at the target end. */
|
/** Marker shape at the target end. Legacy boolean (`true`=arrow) coerced on read. */
|
||||||
marker_end?: boolean
|
marker_end?: MarkerShape | boolean
|
||||||
waypoints?: Waypoint[]
|
waypoints?: Waypoint[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,10 +268,10 @@ export interface EdgeTypeStyle {
|
|||||||
opacity: number
|
opacity: number
|
||||||
pathStyle: EdgePathStyle
|
pathStyle: EdgePathStyle
|
||||||
animated: 'none' | 'snake' | 'flow' | 'basic'
|
animated: 'none' | 'snake' | 'flow' | 'basic'
|
||||||
/** Default filled arrowhead at the source end for new edges of this type. */
|
/** Default marker shape at the source end for new edges of this type. */
|
||||||
arrowStart: boolean
|
arrowStart: MarkerShape
|
||||||
/** Default filled arrowhead at the target end for new edges of this type. */
|
/** Default marker shape at the target end for new edges of this type. */
|
||||||
arrowEnd: boolean
|
arrowEnd: MarkerShape
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomStyleDef {
|
export interface CustomStyleDef {
|
||||||
|
|||||||
@@ -243,17 +243,24 @@ describe('serializeEdge', () => {
|
|||||||
expect(result.animated).toBe(true)
|
expect(result.animated).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('serializes arrow markers', () => {
|
it('serializes endpoint marker shapes', () => {
|
||||||
const edge = makeRfEdge({ data: { type: 'ethernet', marker_start: true, marker_end: true } })
|
const edge = makeRfEdge({ data: { type: 'ethernet', marker_start: 'diamond', marker_end: 'arrow' } })
|
||||||
const result = serializeEdge(edge)
|
const result = serializeEdge(edge)
|
||||||
expect(result.marker_start).toBe(true)
|
expect(result.marker_start).toBe('diamond')
|
||||||
expect(result.marker_end).toBe(true)
|
expect(result.marker_end).toBe('arrow')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('defaults arrow markers to false when absent', () => {
|
it('coerces legacy boolean markers to shape strings', () => {
|
||||||
|
const edge = makeRfEdge({ data: { type: 'ethernet', marker_start: true, marker_end: false } })
|
||||||
|
const result = serializeEdge(edge)
|
||||||
|
expect(result.marker_start).toBe('arrow')
|
||||||
|
expect(result.marker_end).toBe('none')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults endpoint markers to "none" when absent', () => {
|
||||||
const result = serializeEdge(makeRfEdge())
|
const result = serializeEdge(makeRfEdge())
|
||||||
expect(result.marker_start).toBe(false)
|
expect(result.marker_start).toBe('none')
|
||||||
expect(result.marker_end).toBe(false)
|
expect(result.marker_end).toBe('none')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('nulls optional fields when absent', () => {
|
it('nulls optional fields when absent', () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Node, Edge } from '@xyflow/react'
|
import type { Node, Edge } from '@xyflow/react'
|
||||||
import type { NodeData, EdgeData, Waypoint } from '@/types'
|
import type { NodeData, EdgeData, Waypoint } from '@/types'
|
||||||
import { normalizeHandle, clampHandles, handleId, handleCountField, type Side } from '@/utils/handleUtils'
|
import { normalizeHandle, clampHandles, handleId, handleCountField, type Side } from '@/utils/handleUtils'
|
||||||
|
import { normalizeMarker } from '@/utils/edgeMarkers'
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -49,8 +50,8 @@ export interface ApiEdge {
|
|||||||
custom_color?: string | null
|
custom_color?: string | null
|
||||||
path_style?: string | null
|
path_style?: string | null
|
||||||
animated?: boolean | 'snake' | 'flow' | 'basic' | 'none'
|
animated?: boolean | 'snake' | 'flow' | 'basic' | 'none'
|
||||||
marker_start?: boolean | null
|
marker_start?: string | boolean | null
|
||||||
marker_end?: boolean | null
|
marker_end?: string | boolean | null
|
||||||
source_handle?: string | null
|
source_handle?: string | null
|
||||||
target_handle?: string | null
|
target_handle?: string | null
|
||||||
waypoints?: Waypoint[] | null
|
waypoints?: Waypoint[] | null
|
||||||
@@ -144,8 +145,8 @@ export function serializeEdge(e: Edge<EdgeData>): Record<string, unknown> {
|
|||||||
custom_color: e.data?.custom_color ?? null,
|
custom_color: e.data?.custom_color ?? null,
|
||||||
path_style: e.data?.path_style ?? null,
|
path_style: e.data?.path_style ?? null,
|
||||||
animated: e.data?.animated ?? false,
|
animated: e.data?.animated ?? false,
|
||||||
marker_start: e.data?.marker_start ?? false,
|
marker_start: normalizeMarker(e.data?.marker_start),
|
||||||
marker_end: e.data?.marker_end ?? false,
|
marker_end: normalizeMarker(e.data?.marker_end),
|
||||||
source_handle: normalizeHandle(e.sourceHandle),
|
source_handle: normalizeHandle(e.sourceHandle),
|
||||||
target_handle: normalizeHandle(e.targetHandle),
|
target_handle: normalizeHandle(e.targetHandle),
|
||||||
waypoints: e.data?.waypoints?.length ? e.data.waypoints : null,
|
waypoints: e.data?.waypoints?.length ? e.data.waypoints : null,
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { MarkerShape } from '@/types'
|
||||||
|
|
||||||
|
/** All selectable marker shapes, in picker order. */
|
||||||
|
export const MARKER_SHAPES: MarkerShape[] = [
|
||||||
|
'none', 'arrow', 'arrow-open', 'circle', 'diamond', 'square',
|
||||||
|
]
|
||||||
|
|
||||||
|
const SHAPE_SET = new Set<string>(MARKER_SHAPES)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coerce any stored/legacy marker value into a MarkerShape.
|
||||||
|
* - legacy boolean `true` → 'arrow'
|
||||||
|
* - legacy boolean `false` / null / undefined → 'none'
|
||||||
|
* - a valid shape string passes through
|
||||||
|
* - anything unknown → 'none'
|
||||||
|
*/
|
||||||
|
export function normalizeMarker(v: unknown): MarkerShape {
|
||||||
|
if (v === true) return 'arrow'
|
||||||
|
if (v === false || v == null) return 'none'
|
||||||
|
if (typeof v === 'string' && SHAPE_SET.has(v)) return v as MarkerShape
|
||||||
|
return 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NonNoneMarkerShape = Exclude<MarkerShape, 'none'>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SVG <marker> geometry per shape, drawn in a 0..10 viewBox.
|
||||||
|
* - `refX` positions the shape on the endpoint: directional shapes (arrow,
|
||||||
|
* arrow-open) put their tip on the point; symmetric caps (circle, diamond,
|
||||||
|
* square) centre on it.
|
||||||
|
* - `directional` shapes rotate with the edge; symmetric ones don't care.
|
||||||
|
*/
|
||||||
|
export const MARKER_GEOMETRY: Record<
|
||||||
|
NonNoneMarkerShape,
|
||||||
|
{ refX: number; directional: boolean }
|
||||||
|
> = {
|
||||||
|
arrow: { refX: 9, directional: true },
|
||||||
|
'arrow-open': { refX: 8.5, directional: true },
|
||||||
|
circle: { refX: 5, directional: false },
|
||||||
|
diamond: { refX: 5, directional: false },
|
||||||
|
square: { refX: 5, directional: false },
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user