From c95d104245fb6dfd0e285d8f139e9badacdb91af Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 5 Jul 2026 11:30:49 +0200 Subject: [PATCH] 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 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 --- backend/app/db/database.py | 4 +- backend/app/db/models.py | 4 +- backend/app/schemas/canvas.py | 11 ++- backend/app/schemas/edges.py | 22 ++++-- backend/app/schemas/utils.py | 18 +++++ backend/tests/test_canvas.py | 25 +++++-- backend/tests/test_edges.py | 36 +++++++--- .../__tests__/HomelableEdge.markers.test.tsx | 54 ++++++++++++--- .../src/components/canvas/edges/index.tsx | 67 +++++++++++++------ .../components/modals/CustomStyleModal.tsx | 27 ++------ frontend/src/components/modals/EdgeModal.tsx | 32 +++------ .../components/modals/MarkerShapePicker.tsx | 60 +++++++++++++++++ .../__tests__/CustomStyleModal.test.tsx | 18 ++--- .../modals/__tests__/EdgeModal.test.tsx | 47 ++++++++----- .../src/stores/__tests__/canvasStore.test.ts | 20 +++--- .../src/stores/__tests__/themeStore.test.ts | 2 +- frontend/src/types/index.ts | 23 ++++--- .../utils/__tests__/canvasSerializer.test.ts | 21 ++++-- frontend/src/utils/canvasSerializer.ts | 9 +-- frontend/src/utils/edgeMarkers.ts | 42 ++++++++++++ 20 files changed, 385 insertions(+), 157 deletions(-) create mode 100644 frontend/src/components/modals/MarkerShapePicker.tsx create mode 100644 frontend/src/utils/edgeMarkers.ts diff --git a/backend/app/db/database.py b/backend/app/db/database.py index ca7b9f6..c574b5e 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -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): diff --git a/backend/app/db/models.py b/backend/app/db/models.py index a47a974..a81a805 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -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) diff --git a/backend/app/schemas/canvas.py b/backend/app/schemas/canvas.py index 86892f3..6bbfd52 100644 --- a/backend/app/schemas/canvas.py +++ b/backend/app/schemas/canvas.py @@ -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] = [] diff --git a/backend/app/schemas/edges.py b/backend/app/schemas/edges.py index 1924115..6fe021e 100644 --- a/backend/app/schemas/edges.py +++ b/backend/app/schemas/edges.py @@ -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 diff --git a/backend/app/schemas/utils.py b/backend/app/schemas/utils.py index 21e8770..6f9a924 100644 --- a/backend/app/schemas/utils.py +++ b/backend/app/schemas/utils.py @@ -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' diff --git a/backend/tests/test_canvas.py b/backend/tests/test_canvas.py index 689685d..9e320e5 100644 --- a/backend/tests/test_canvas.py +++ b/backend/tests/test_canvas.py @@ -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): diff --git a/backend/tests/test_edges.py b/backend/tests/test_edges.py index 489aa30..d60a6ab 100644 --- a/backend/tests/test_edges.py +++ b/backend/tests/test_edges.py @@ -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): diff --git a/frontend/src/components/canvas/edges/__tests__/HomelableEdge.markers.test.tsx b/frontend/src/components/canvas/edges/__tests__/HomelableEdge.markers.test.tsx index b526946..67e9916 100644 --- a/frontend/src/components/canvas/edges/__tests__/HomelableEdge.markers.test.tsx +++ b/frontend/src/components/canvas/edges/__tests__/HomelableEdge.markers.test.tsx @@ -6,9 +6,10 @@ import { HomelableEdge } from '../index' import type { EdgeData } from '@/types' /** - * Arrowhead endpoints: filled-triangle defs, independently toggleable - * at start/end, filled with the live stroke color, and referenced by BaseEdge - * via markerStart/markerEnd URLs. + * Endpoint markers: per-end shape (arrow / arrow-open / circle / diamond / + * square) defs, independently selectable at start/end, filled with the + * live stroke color, referenced by BaseEdge via markerStart/markerEnd URLs. + * Legacy boolean values coerce to the filled 'arrow' shape. */ function renderEdge(data: Partial = {}, selected = false) { const props = { @@ -41,7 +42,7 @@ describe('HomelableEdge arrow markers', () => { }) 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') expect(marker).toBeTruthy() expect(container.querySelector('#arrow-start-e1')).toBeNull() @@ -51,21 +52,58 @@ describe('HomelableEdge arrow markers', () => { expect(referenced).toBe(true) }) - it('renders a start marker with reversed orientation', () => { - const { container } = renderEdge({ marker_start: true }) + it('coerces a legacy boolean marker to the filled arrow shape', () => { + 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') expect(marker).toBeTruthy() expect(marker?.getAttribute('orient')).toBe('auto-start-reverse') }) + it('renders a circle marker as a , 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 ', () => { + 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', () => { - 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-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', () => { - 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') expect(fill).toBe('#ff6e00') }) diff --git a/frontend/src/components/canvas/edges/index.tsx b/frontend/src/components/canvas/edges/index.tsx index 52e456b..9cb1f22 100644 --- a/frontend/src/components/canvas/edges/index.tsx +++ b/frontend/src/components/canvas/edges/index.tsx @@ -13,6 +13,7 @@ import type { EdgeData, EdgeType, Waypoint } from '@/types' import { useThemeStore } from '@/stores/themeStore' import { useCanvasStore } from '@/stores/canvasStore' import { THEMES } from '@/utils/themes' +import { MARKER_GEOMETRY, normalizeMarker, type NonNoneMarkerShape } from '@/utils/edgeMarkers' import { buildWaypointPath, getAddWaypointHandlePosition, getWaypointLabelPosition, snap45, snap45both } from './waypointUtils' 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] } +/** Inner SVG element for an edge , drawn in a 0..10 viewBox. */ +function markerInnerElement(shape: NonNoneMarkerShape, color: string): React.ReactElement { + switch (shape) { + case 'arrow': + return + case 'arrow-open': + return + case 'circle': + return + case 'diamond': + return + case 'square': + return + } +} + // ── Waypoint drag handle ───────────────────────────────────────────────────── interface WaypointHandleProps { @@ -351,37 +368,43 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle ? segmentMidpoints(sourceX, sourceY, waypoints, targetX, targetY, pathStyle, sourcePosition) : [] - // ── Arrowheads ───────────────────────────────────────────────────────────── + // ── Endpoint markers ─────────────────────────────────────────────────────── // Custom inline 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 + // Each end picks its own shape (arrow / arrow-open / circle / diamond / square) + // 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 markerSize = 6 + strokeW * 2 const startMarkerId = `arrow-start-${id}` const endMarkerId = `arrow-end-${id}` - const arrowMarker = (markerId: string, orient: string) => ( - - - - ) + const arrowMarker = (markerId: string, shape: NonNoneMarkerShape, orient: string) => { + const geo = MARKER_GEOMETRY[shape] + return ( + + {markerInnerElement(shape, strokeColor)} + + ) + } return ( <> - {(markerStart || markerEnd) && ( + {hasMarkers && ( - {markerStart && arrowMarker(startMarkerId, 'auto-start-reverse')} - {markerEnd && arrowMarker(endMarkerId, 'auto')} + {startShape !== 'none' && arrowMarker(startMarkerId, startShape, 'auto-start-reverse')} + {endShape !== 'none' && arrowMarker(endMarkerId, endShape, 'auto')} )} @@ -390,8 +413,8 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle path={edgePath} style={animMode === 'basic' ? { ...style, stroke: 'transparent' } : style} interactionWidth={16} - markerStart={markerStart ? `url(#${startMarkerId})` : undefined} - markerEnd={markerEnd ? `url(#${endMarkerId})` : undefined} + markerStart={startShape !== 'none' ? `url(#${startMarkerId})` : undefined} + markerEnd={endShape !== 'none' ? `url(#${endMarkerId})` : undefined} /> {animMode === 'basic' && ( diff --git a/frontend/src/components/modals/CustomStyleModal.tsx b/frontend/src/components/modals/CustomStyleModal.tsx index 1df9803..bed7c05 100644 --- a/frontend/src/components/modals/CustomStyleModal.tsx +++ b/frontend/src/components/modals/CustomStyleModal.tsx @@ -17,6 +17,7 @@ import type { NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, EdgePathStyle, } 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) ── @@ -64,8 +65,8 @@ function defaultEdgeStyle(edgeType: EdgeType): EdgeTypeStyle { opacity: 1, pathStyle: 'bezier', animated: 'none', - arrowStart: false, - arrowEnd: false, + arrowStart: 'none', + arrowEnd: 'none', } } @@ -283,24 +284,10 @@ function EdgeEditor({ edgeType, style, onChange, onApplyToExisting }: EdgeEditor
-
Arrows
-
- {([['Start', 'arrowStart'], ['End', 'arrowEnd']] as [string, 'arrowStart' | 'arrowEnd'][]).map(([label, key]) => ( - - ))} +
Endpoints
+
+ set('arrowStart', s)} /> + set('arrowEnd', s)} />
diff --git a/frontend/src/components/modals/EdgeModal.tsx b/frontend/src/components/modals/EdgeModal.tsx index 20212f3..419050d 100644 --- a/frontend/src/components/modals/EdgeModal.tsx +++ b/frontend/src/components/modals/EdgeModal.tsx @@ -7,8 +7,10 @@ import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { Label } from '@/components/ui/label' 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 { normalizeMarker } from '@/utils/edgeMarkers' +import { MarkerShapePicker } from './MarkerShapePicker' 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(initial?.custom_color) const [pathStyle, setPathStyle] = useState(initial?.path_style ?? 'bezier') const [animation, setAnimation] = useState(() => toAnimMode(initial?.animated)) - const [markerStart, setMarkerStart] = useState(initial?.marker_start ?? false) - const [markerEnd, setMarkerEnd] = useState(initial?.marker_end ?? false) + const [markerStart, setMarkerStart] = useState(normalizeMarker(initial?.marker_start)) + const [markerEnd, setMarkerEnd] = useState(normalizeMarker(initial?.marker_end)) const effectiveColor = customColor ?? EDGE_DEFAULT_COLORS[type] @@ -158,26 +160,10 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
- -
- {([['Start', markerStart, setMarkerStart], ['End', markerEnd, setMarkerEnd]] as [string, boolean, (v: boolean) => void][]).map(([label, active, set], i) => ( - - ))} + +
+ +
diff --git a/frontend/src/components/modals/MarkerShapePicker.tsx b/frontend/src/components/modals/MarkerShapePicker.tsx new file mode 100644 index 0000000..67429fb --- /dev/null +++ b/frontend/src/components/modals/MarkerShapePicker.tsx @@ -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 + case 'arrow': + return + case 'arrow-open': + return + case 'circle': + return + case 'diamond': + return + case 'square': + return + } +} + +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 ( +
+ {label} +
+ {MARKER_SHAPES.map((shape) => { + const active = value === shape + return ( + + ) + })} +
+
+ ) +} diff --git a/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx b/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx index 65cd47b..6d27d64 100644 --- a/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx +++ b/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx @@ -124,26 +124,26 @@ describe('CustomStyleModal', () => { 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() fireEvent.click(screen.getByRole('button', { name: 'Edges' })) fireEvent.click(screen.getByRole('button', { name: /Ethernet/ })) - const startBtn = screen.getByRole('button', { name: 'Start' }) - const endBtn = screen.getByRole('button', { name: 'End' }) - expect(startBtn.getAttribute('aria-pressed')).toBe('false') - expect(endBtn.getAttribute('aria-pressed')).toBe('false') + const startNone = screen.getByRole('button', { name: 'Start marker none' }) + const endNone = screen.getByRole('button', { name: 'End marker none' }) + expect(startNone.getAttribute('aria-pressed')).toBe('true') + 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() useCanvasStore.setState({ applyTypeEdgeStyle }) render() fireEvent.click(screen.getByRole('button', { name: 'Edges' })) 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/ })) - expect(applyTypeEdgeStyle.mock.calls[0][1].arrowEnd).toBe(true) - expect(applyTypeEdgeStyle.mock.calls[0][1].arrowStart).toBe(false) + expect(applyTypeEdgeStyle.mock.calls[0][1].arrowEnd).toBe('diamond') + expect(applyTypeEdgeStyle.mock.calls[0][1].arrowStart).toBe('none') }) it('editing path style updates the edge draft', () => { diff --git a/frontend/src/components/modals/__tests__/EdgeModal.test.tsx b/frontend/src/components/modals/__tests__/EdgeModal.test.tsx index 75dcf82..ea32cde 100644 --- a/frontend/src/components/modals/__tests__/EdgeModal.test.tsx +++ b/frontend/src/components/modals/__tests__/EdgeModal.test.tsx @@ -165,39 +165,56 @@ describe('EdgeModal', () => { 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() render() fireEvent.click(screen.getByRole('button', { name: 'Connect' })) - expect(onSubmit.mock.calls[0][0].marker_start).toBe(false) - expect(onSubmit.mock.calls[0][0].marker_end).toBe(false) + expect(onSubmit.mock.calls[0][0].marker_start).toBe('none') + 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() render() - fireEvent.click(screen.getByRole('button', { name: /Arrow End/ })) + fireEvent.click(screen.getByRole('button', { name: 'End marker arrow' })) fireEvent.click(screen.getByRole('button', { name: 'Connect' })) - expect(onSubmit.mock.calls[0][0].marker_end).toBe(true) - expect(onSubmit.mock.calls[0][0].marker_start).toBe(false) + expect(onSubmit.mock.calls[0][0].marker_end).toBe('arrow') + 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() render() - fireEvent.click(screen.getByRole('button', { name: /Arrow Start/ })) + fireEvent.click(screen.getByRole('button', { name: 'Start marker circle' })) 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() - render() + render() + 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' })) - expect(onSubmit.mock.calls[0][0].marker_start).toBe(true) - expect(onSubmit.mock.calls[0][0].marker_end).toBe(true) + expect(onSubmit.mock.calls[0][0].marker_start).toBe('diamond') + expect(onSubmit.mock.calls[0][0].marker_end).toBe('square') + }) + + it('pre-fills endpoint shapes from initial', () => { + const onSubmit = vi.fn() + render() + 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() + 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', () => { diff --git a/frontend/src/stores/__tests__/canvasStore.test.ts b/frontend/src/stores/__tests__/canvasStore.test.ts index 35c53ae..6e87063 100644 --- a/frontend/src/stores/__tests__/canvasStore.test.ts +++ b/frontend/src/stores/__tests__/canvasStore.test.ts @@ -343,12 +343,12 @@ describe('canvasStore', () => { expect(edges[0].id).not.toBe(edges[1].id) }) - it('onConnect preserves arrow markers from edge data', () => { - const conn = Object.assign({ source: 'n1', target: 'n2', sourceHandle: null, targetHandle: null }, { type: 'ethernet', marker_start: true, marker_end: true }) + 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: 'diamond', marker_end: 'arrow' }) useCanvasStore.getState().onConnect(conn) const { edges } = useCanvasStore.getState() - expect(edges[0].data?.marker_start).toBe(true) - expect(edges[0].data?.marker_end).toBe(true) + expect(edges[0].data?.marker_start).toBe('diamond') + expect(edges[0].data?.marker_end).toBe('arrow') }) it('onConnect preserves sourceHandle and targetHandle for cluster edges', () => { @@ -1320,15 +1320,15 @@ describe('canvasStore — custom style apply', () => { const e2: Edge = { id: 'e2', source: 'n1', target: 'n2', type: 'wifi', data: { type: 'wifi' } } 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 updated2 = useCanvasStore.getState().edges.find((e) => e.id === 'e2')! expect(updated1.data?.custom_color).toBe('#00ff00') expect(updated1.data?.path_style).toBe('smooth') expect(updated1.data?.animated).toBe('flow') - expect(updated1.data?.marker_start).toBe(true) - expect(updated1.data?.marker_end).toBe(true) + expect(updated1.data?.marker_start).toBe('circle') + expect(updated1.data?.marker_end).toBe('arrow') expect(updated2.data?.custom_color).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 }, }, 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(ns.data.custom_colors?.border).toBeUndefined() expect(e.data?.custom_color).toBe('#aabbcc') - expect(e.data?.marker_end).toBe(true) - expect(e.data?.marker_start).toBe(false) + expect(e.data?.marker_end).toBe('square') + expect(e.data?.marker_start).toBe('none') expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) }) diff --git a/frontend/src/stores/__tests__/themeStore.test.ts b/frontend/src/stores/__tests__/themeStore.test.ts index db6ac1f..70c5d8c 100644 --- a/frontend/src/stores/__tests__/themeStore.test.ts +++ b/frontend/src/stores/__tests__/themeStore.test.ts @@ -39,7 +39,7 @@ describe('themeStore', () => { it('setCustomStyle replaces the entire definition', () => { const def: CustomStyleDef = { 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) expect(useThemeStore.getState().customStyle.nodes.server?.borderColor).toBe('#ff0000') diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 5fd59f1..9fdddcf 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -156,6 +156,13 @@ export interface NodeData extends Record { 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 { x: number y: number @@ -169,10 +176,10 @@ export interface EdgeData extends Record { custom_color?: string path_style?: EdgePathStyle animated?: boolean | 'snake' | 'flow' | 'basic' | 'none' - /** Filled arrowhead at the source end. */ - marker_start?: boolean - /** Filled arrowhead at the target end. */ - marker_end?: boolean + /** Marker shape at the source end. Legacy boolean (`true`=arrow) coerced on read. */ + marker_start?: MarkerShape | boolean + /** Marker shape at the target end. Legacy boolean (`true`=arrow) coerced on read. */ + marker_end?: MarkerShape | boolean waypoints?: Waypoint[] } @@ -261,10 +268,10 @@ export interface EdgeTypeStyle { opacity: number pathStyle: EdgePathStyle animated: 'none' | 'snake' | 'flow' | 'basic' - /** Default filled arrowhead at the source end for new edges of this type. */ - arrowStart: boolean - /** Default filled arrowhead at the target end for new edges of this type. */ - arrowEnd: boolean + /** Default marker shape at the source end for new edges of this type. */ + arrowStart: MarkerShape + /** Default marker shape at the target end for new edges of this type. */ + arrowEnd: MarkerShape } export interface CustomStyleDef { diff --git a/frontend/src/utils/__tests__/canvasSerializer.test.ts b/frontend/src/utils/__tests__/canvasSerializer.test.ts index c1162fc..dc88d48 100644 --- a/frontend/src/utils/__tests__/canvasSerializer.test.ts +++ b/frontend/src/utils/__tests__/canvasSerializer.test.ts @@ -243,17 +243,24 @@ describe('serializeEdge', () => { expect(result.animated).toBe(true) }) - it('serializes arrow markers', () => { - const edge = makeRfEdge({ data: { type: 'ethernet', marker_start: true, marker_end: true } }) + it('serializes endpoint marker shapes', () => { + const edge = makeRfEdge({ data: { type: 'ethernet', marker_start: 'diamond', marker_end: 'arrow' } }) const result = serializeEdge(edge) - expect(result.marker_start).toBe(true) - expect(result.marker_end).toBe(true) + expect(result.marker_start).toBe('diamond') + 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()) - expect(result.marker_start).toBe(false) - expect(result.marker_end).toBe(false) + expect(result.marker_start).toBe('none') + expect(result.marker_end).toBe('none') }) it('nulls optional fields when absent', () => { diff --git a/frontend/src/utils/canvasSerializer.ts b/frontend/src/utils/canvasSerializer.ts index 5c53690..86bc16c 100644 --- a/frontend/src/utils/canvasSerializer.ts +++ b/frontend/src/utils/canvasSerializer.ts @@ -1,6 +1,7 @@ import type { Node, Edge } from '@xyflow/react' import type { NodeData, EdgeData, Waypoint } from '@/types' import { normalizeHandle, clampHandles, handleId, handleCountField, type Side } from '@/utils/handleUtils' +import { normalizeMarker } from '@/utils/edgeMarkers' // ── Types ──────────────────────────────────────────────────────────────────── @@ -49,8 +50,8 @@ export interface ApiEdge { custom_color?: string | null path_style?: string | null animated?: boolean | 'snake' | 'flow' | 'basic' | 'none' - marker_start?: boolean | null - marker_end?: boolean | null + marker_start?: string | boolean | null + marker_end?: string | boolean | null source_handle?: string | null target_handle?: string | null waypoints?: Waypoint[] | null @@ -144,8 +145,8 @@ export function serializeEdge(e: Edge): Record { custom_color: e.data?.custom_color ?? null, path_style: e.data?.path_style ?? null, animated: e.data?.animated ?? false, - marker_start: e.data?.marker_start ?? false, - marker_end: e.data?.marker_end ?? false, + marker_start: normalizeMarker(e.data?.marker_start), + marker_end: normalizeMarker(e.data?.marker_end), source_handle: normalizeHandle(e.sourceHandle), target_handle: normalizeHandle(e.targetHandle), waypoints: e.data?.waypoints?.length ? e.data.waypoints : null, diff --git a/frontend/src/utils/edgeMarkers.ts b/frontend/src/utils/edgeMarkers.ts new file mode 100644 index 0000000..736b4cf --- /dev/null +++ b/frontend/src/utils/edgeMarkers.ts @@ -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(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 + +/** + * SVG 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 }, +}