diff --git a/backend/app/db/database.py b/backend/app/db/database.py index c574b5e..2ffeaae 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -85,6 +85,10 @@ async def init_db() -> None: 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 TEXT NOT NULL DEFAULT 'none'") + with suppress(OperationalError): + await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN line_style TEXT") + with suppress(OperationalError): + await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN width_mult REAL") 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 a81a805..144f976 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -85,6 +85,8 @@ class Edge(Base): speed: Mapped[str | None] = mapped_column(String) custom_color: Mapped[str | None] = mapped_column(String) path_style: Mapped[str | None] = mapped_column(String) + line_style: Mapped[str | None] = mapped_column(String) + width_mult: Mapped[float | None] = mapped_column(Float) animated: Mapped[str] = mapped_column(String, nullable=False, default='none') marker_start: Mapped[str] = mapped_column(String, nullable=False, default='none') marker_end: Mapped[str] = mapped_column(String, nullable=False, default='none') diff --git a/backend/app/schemas/canvas.py b/backend/app/schemas/canvas.py index 6bbfd52..9a62230 100644 --- a/backend/app/schemas/canvas.py +++ b/backend/app/schemas/canvas.py @@ -51,6 +51,8 @@ class EdgeSave(BaseModel): speed: str | None = None custom_color: str | None = None path_style: str | None = None + line_style: str | None = None + width_mult: float | None = None animated: str = 'none' marker_start: str = 'none' marker_end: str = 'none' diff --git a/backend/app/schemas/edges.py b/backend/app/schemas/edges.py index 6fe021e..307e120 100644 --- a/backend/app/schemas/edges.py +++ b/backend/app/schemas/edges.py @@ -14,6 +14,8 @@ class EdgeBase(BaseModel): speed: str | None = None custom_color: str | None = None path_style: str | None = None + line_style: str | None = None + width_mult: float | None = None animated: str = 'none' marker_start: str = 'none' marker_end: str = 'none' @@ -43,6 +45,8 @@ class EdgeUpdate(BaseModel): speed: str | None = None custom_color: str | None = None path_style: str | None = None + line_style: str | None = None + width_mult: float | None = None animated: str | None = None marker_start: str | None = None marker_end: str | None = None diff --git a/backend/tests/test_canvas.py b/backend/tests/test_canvas.py index 9e320e5..af827a2 100644 --- a/backend/tests/test_canvas.py +++ b/backend/tests/test_canvas.py @@ -62,6 +62,17 @@ async def test_save_canvas_round_trips_marker_shapes(client: AsyncClient, header assert edge["marker_end"] == "arrow" +async def test_save_canvas_round_trips_line_style_and_width(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"], type="wifi", line_style="dotted", width_mult=3) + 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["line_style"] == "dotted" + assert edge["width_mult"] == 3 + + 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") diff --git a/backend/tests/test_edges.py b/backend/tests/test_edges.py index d60a6ab..c79da5c 100644 --- a/backend/tests/test_edges.py +++ b/backend/tests/test_edges.py @@ -91,6 +91,31 @@ 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_line_style_and_width(client: AsyncClient, headers: dict, two_nodes): + src, tgt = two_nodes + res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "wifi", "line_style": "dotted", "width_mult": 3}, headers=headers) + assert res.status_code == 201 + assert res.json()["line_style"] == "dotted" + assert res.json()["width_mult"] == 3 + + +async def test_update_edge_line_style_and_width(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={"line_style": "dashed", "width_mult": 2}, headers=headers) + assert res.status_code == 200 + assert res.json()["line_style"] == "dashed" + assert res.json()["width_mult"] == 2 + + +async def test_create_edge_defaults_line_style_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()["line_style"] is None + assert res.json()["width_mult"] is None + + 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": "diamond", "marker_end": "arrow"}, headers=headers) diff --git a/frontend/src/components/canvas/edges/__tests__/HomelableEdge.lineStyle.test.tsx b/frontend/src/components/canvas/edges/__tests__/HomelableEdge.lineStyle.test.tsx new file mode 100644 index 0000000..a48d7ad --- /dev/null +++ b/frontend/src/components/canvas/edges/__tests__/HomelableEdge.lineStyle.test.tsx @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest' +import { render } from '@testing-library/react' +import { ReactFlowProvider } from '@xyflow/react' +import type { EdgeProps, Edge } from '@xyflow/react' +import { HomelableEdge } from '../index' +import type { EdgeData } from '@/types' + +/** + * Per-edge line render: `line_style` overrides the type's dash preset and + * `width_mult` scales the type base stroke width (1×–4×). Both are optional — + * unset leaves the edge type's default look untouched. + */ +function renderEdge(data: Partial = {}) { + const props = { + id: 'e1', + source: 'a', + target: 'b', + sourceX: 0, + sourceY: 0, + targetX: 100, + targetY: 100, + sourcePosition: 'bottom', + targetPosition: 'top', + data: { type: 'ethernet', ...data } as EdgeData, + selected: false, + } as unknown as EdgeProps> + + return render( + + + + + , + ) +} + +/** The BaseEdge path is the one carrying the interaction width. */ +function edgePath(container: HTMLElement): SVGPathElement { + return container.querySelector('path.react-flow__edge-path') as SVGPathElement + ?? (container.querySelector('path') as SVGPathElement) +} + +describe('HomelableEdge line style + width', () => { + it('scales stroke width by the multiplier (ethernet base 2 × 3 = 6)', () => { + const { container } = renderEdge({ width_mult: 3 }) + expect(edgePath(container).style.strokeWidth).toBe('6') + }) + + it('keeps the base width when no multiplier is set', () => { + const { container } = renderEdge() + expect(edgePath(container).style.strokeWidth).toBe('2') + }) + + it('applies a dash pattern for a dashed line style', () => { + const { container } = renderEdge({ line_style: 'dashed', width_mult: 2 }) + // width 4 → dashed "12 8" + expect(edgePath(container).style.strokeDasharray.replace(/,/g, '')).toBe('12 8') + }) + + it('uses a round cap for dotted lines', () => { + const { container } = renderEdge({ line_style: 'dotted' }) + expect(edgePath(container).style.strokeLinecap).toBe('round') + }) + + it('clears the preset dash for a solid override', () => { + // wifi defaults to a dashed preset; solid override removes it + const { container } = renderEdge({ type: 'wifi', line_style: 'solid' }) + expect(edgePath(container).style.strokeDasharray).toBe('') + }) +}) diff --git a/frontend/src/components/canvas/edges/index.tsx b/frontend/src/components/canvas/edges/index.tsx index 9cb1f22..3bfd6b5 100644 --- a/frontend/src/components/canvas/edges/index.tsx +++ b/frontend/src/components/canvas/edges/index.tsx @@ -9,11 +9,12 @@ import { type EdgeProps, type Edge, } from '@xyflow/react' -import type { EdgeData, EdgeType, Waypoint } from '@/types' +import type { EdgeData, EdgeLineStyle, 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 { clampWidthMult, dashArrayFor } from '@/utils/edgeLineStyle' import { buildWaypointPath, getAddWaypointHandlePosition, getWaypointLabelPosition, snap45, snap45both } from './waypointUtils' const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149'] @@ -350,8 +351,23 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle : customColor ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : (BASE_STYLES[edgeType].stroke as string ?? edgeColors.ethernet)) + // Per-edge line render overrides (custom style editor). Width multiplies the + // type's base width; line style overrides the preset dash pattern. Both are + // optional — unset leaves the type default from BASE_STYLES untouched. + const baseWidth = (BASE_STYLES[edgeType].strokeWidth as number) ?? 2 + const widthMult = clampWidthMult(data?.width_mult as number | undefined) + const resolvedWidth = baseWidth * widthMult + const lineStyleOverride = data?.line_style as EdgeLineStyle | undefined + const style: React.CSSProperties = { ...BASE_STYLES[edgeType], + strokeWidth: resolvedWidth, + ...(lineStyleOverride + ? { + strokeDasharray: dashArrayFor(lineStyleOverride, resolvedWidth), + strokeLinecap: lineStyleOverride === 'dotted' ? 'round' : 'butt', + } + : {}), ...(edgeType === 'vlan' ? { stroke: getVlanColor(data?.vlan_id as number | undefined) } : {}), ...(customColor ? { stroke: customColor } : {}), ...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}), diff --git a/frontend/src/components/modals/CustomStyleModal.tsx b/frontend/src/components/modals/CustomStyleModal.tsx index bed7c05..ab1b0d6 100644 --- a/frontend/src/components/modals/CustomStyleModal.tsx +++ b/frontend/src/components/modals/CustomStyleModal.tsx @@ -14,9 +14,13 @@ import { clampHandles, sideDefault } from '@/utils/handleUtils' import { THEMES } from '@/utils/themes' import { applyOpacity } from '@/utils/colorUtils' import type { - NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, EdgePathStyle, + NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, EdgePathStyle, EdgeLineStyle, } from '@/types' import { NODE_TYPE_LABELS, EDGE_TYPE_LABELS } from '@/types' +import { + EDGE_LINE_STYLES, EDGE_LINE_STYLE_LABELS, EDGE_TYPE_BASE_WIDTH, EDGE_TYPE_DEFAULT_LINE, + clampWidthMult, dashArrayFor, +} from '@/utils/edgeLineStyle' import { MarkerShapePicker } from './MarkerShapePicker' // ── Node types exposed for custom style, grouped by category (skip groupRect/group) ── @@ -64,12 +68,41 @@ function defaultEdgeStyle(edgeType: EdgeType): EdgeTypeStyle { color: THEMES.default.colors.edgeColors[edgeType], opacity: 1, pathStyle: 'bezier', + lineStyle: EDGE_TYPE_DEFAULT_LINE[edgeType], + widthMult: 1, animated: 'none', arrowStart: 'none', arrowEnd: 'none', } } +// ── Edge line preview (renders the actual dash pattern + width) ──────────────── + +interface EdgeLineSwatchProps { + color: string + lineStyle: EdgeLineStyle + strokeWidth: number + width?: number +} + +function EdgeLineSwatch({ color, lineStyle, strokeWidth, width = 40 }: EdgeLineSwatchProps) { + const h = 12 + return ( + + + + ) +} + // ── Color + opacity row ────────────────────────────────────────────────────── interface ColorRowProps { @@ -248,6 +281,52 @@ function EdgeEditor({ edgeType, style, onChange, onApplyToExisting }: EdgeEditor
+
+
+ Line style + +
+
+ {EDGE_LINE_STYLES.map((ls) => ( + + ))} +
+
+ +
+
+ Line width + {style.widthMult}× +
+ set('widthMult', clampWidthMult(parseInt(e.target.value, 10)))} + aria-label="Line width multiplier" + className="w-full h-1 accent-[#00d4ff]" + /> +
+
Path style
@@ -463,6 +542,8 @@ export function CustomStyleModal({ open, onClose, initialNodeType }: CustomStyle const swatchColor = style ? applyOpacity(style.color, style.opacity) : THEMES.default.colors.edgeColors[t] + const lineStyle = style?.lineStyle ?? EDGE_TYPE_DEFAULT_LINE[t] + const widthMult = clampWidthMult(style?.widthMult) return ( ) diff --git a/frontend/src/components/modals/EdgeModal.tsx b/frontend/src/components/modals/EdgeModal.tsx index 419050d..ec7bb49 100644 --- a/frontend/src/components/modals/EdgeModal.tsx +++ b/frontend/src/components/modals/EdgeModal.tsx @@ -7,9 +7,13 @@ 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, type MarkerShape } from '@/types' +import { EDGE_TYPE_LABELS, type EdgeData, type EdgeLineStyle, type EdgePathStyle, type EdgeType, type MarkerShape } from '@/types' import { EDGE_DEFAULT_COLORS } from '@/utils/edgeColors' import { normalizeMarker } from '@/utils/edgeMarkers' +import { + EDGE_LINE_STYLES, EDGE_LINE_STYLE_LABELS, EDGE_TYPE_BASE_WIDTH, EDGE_TYPE_DEFAULT_LINE, + clampWidthMult, dashArrayFor, +} from '@/utils/edgeLineStyle' import { MarkerShapePicker } from './MarkerShapePicker' const EDGE_TYPES = Object.entries(EDGE_TYPE_LABELS) as [EdgeType, string][] @@ -42,8 +46,13 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints, const [animation, setAnimation] = useState(() => toAnimMode(initial?.animated)) const [markerStart, setMarkerStart] = useState(normalizeMarker(initial?.marker_start)) const [markerEnd, setMarkerEnd] = useState(normalizeMarker(initial?.marker_end)) + // Undefined = follow the edge type's default line preset (live, like color). + const [lineStyle, setLineStyle] = useState(initial?.line_style) + const [widthMult, setWidthMult] = useState(clampWidthMult(initial?.width_mult)) const effectiveColor = customColor ?? EDGE_DEFAULT_COLORS[type] + const effectiveLineStyle = lineStyle ?? EDGE_TYPE_DEFAULT_LINE[type] + const previewWidth = EDGE_TYPE_BASE_WIDTH[type] * widthMult const handleSubmit = (e: React.FormEvent) => { e.preventDefault() @@ -53,6 +62,8 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints, vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined, custom_color: customColor, path_style: pathStyle, + line_style: effectiveLineStyle, + width_mult: widthMult, animated: animation !== 'none' ? animation : undefined, marker_start: markerStart, marker_end: markerEnd, @@ -136,6 +147,60 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
+
+
+ + + + +
+
+ {EDGE_LINE_STYLES.map((ls, i) => ( + + ))} +
+
+ +
+
+ + {widthMult}× +
+ setWidthMult(clampWidthMult(parseInt(e.target.value, 10)))} + aria-label="Line width multiplier" + className="w-full h-1 accent-[#00d4ff]" + /> +
+
diff --git a/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx b/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx index 6d27d64..513ddd6 100644 --- a/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx +++ b/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx @@ -146,6 +146,19 @@ describe('CustomStyleModal', () => { expect(applyTypeEdgeStyle.mock.calls[0][1].arrowStart).toBe('none') }) + it('picking a line style + width feeds lineStyle/widthMult 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: 'Dotted' })) + fireEvent.change(screen.getByRole('slider', { name: 'Line width multiplier' }), { target: { value: '3' } }) + fireEvent.click(screen.getByRole('button', { name: /Apply to existing Ethernet/ })) + expect(applyTypeEdgeStyle.mock.calls[0][1].lineStyle).toBe('dotted') + expect(applyTypeEdgeStyle.mock.calls[0][1].widthMult).toBe(3) + }) + it('editing path style updates the edge draft', () => { render() fireEvent.click(screen.getByRole('button', { name: 'Edges' })) diff --git a/frontend/src/components/modals/__tests__/EdgeModal.test.tsx b/frontend/src/components/modals/__tests__/EdgeModal.test.tsx index ea32cde..3f313a7 100644 --- a/frontend/src/components/modals/__tests__/EdgeModal.test.tsx +++ b/frontend/src/components/modals/__tests__/EdgeModal.test.tsx @@ -125,6 +125,41 @@ describe('EdgeModal', () => { expect(onSubmit.mock.calls[0][0].path_style).toBe('smooth') }) + // ── Line style + width ──────────────────────────────────────────────────── + + it('defaults line style to the edge type preset (ethernet → solid)', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + expect(onSubmit.mock.calls[0][0].line_style).toBe('solid') + expect(onSubmit.mock.calls[0][0].width_mult).toBe(1) + }) + + it('follows the type default (wifi → dashed) until overridden', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + expect(onSubmit.mock.calls[0][0].line_style).toBe('dashed') + }) + + it('picking a line style + width sends line_style/width_mult', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Line style dotted' })) + fireEvent.change(screen.getByRole('slider', { name: 'Line width multiplier' }), { target: { value: '4' } }) + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + expect(onSubmit.mock.calls[0][0].line_style).toBe('dotted') + expect(onSubmit.mock.calls[0][0].width_mult).toBe(4) + }) + + it('pre-fills line style + width from initial prop', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + expect(onSubmit.mock.calls[0][0].line_style).toBe('dashed') + expect(onSubmit.mock.calls[0][0].width_mult).toBe(3) + }) + // ── Animation select ────────────────────────────────────────────────────── it('animation defaults to None — animated omitted from payload', () => { diff --git a/frontend/src/stores/__tests__/canvasStore.test.ts b/frontend/src/stores/__tests__/canvasStore.test.ts index 6e87063..0dea6bb 100644 --- a/frontend/src/stores/__tests__/canvasStore.test.ts +++ b/frontend/src/stores/__tests__/canvasStore.test.ts @@ -1320,12 +1320,14 @@ 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: 'circle', arrowEnd: 'arrow' }) + useCanvasStore.getState().applyTypeEdgeStyle('ethernet', { color: '#00ff00', opacity: 1, pathStyle: 'smooth', lineStyle: 'dotted', widthMult: 3, 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?.line_style).toBe('dotted') + expect(updated1.data?.width_mult).toBe(3) expect(updated1.data?.animated).toBe('flow') expect(updated1.data?.marker_start).toBe('circle') expect(updated1.data?.marker_end).toBe('arrow') @@ -1344,7 +1346,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: 'none', arrowEnd: 'square' }, + ethernet: { color: '#aabbcc', opacity: 1, pathStyle: 'bezier', lineStyle: 'dashed', widthMult: 2, animated: 'none', arrowStart: 'none', arrowEnd: 'square' }, }, }) @@ -1354,6 +1356,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?.line_style).toBe('dashed') + expect(e.data?.width_mult).toBe(2) 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/canvasStore.ts b/frontend/src/stores/canvasStore.ts index cc34be0..34be96c 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -279,7 +279,7 @@ export const useCanvasStore = create((set) => ({ sourceHandle: normalizeHandle(extra.sourceHandle), targetHandle: normalizeHandle(extra.targetHandle), type: edgeType, - data: { type: edgeType, label: extra.label, vlan_id: extra.vlan_id, custom_color: extra.custom_color, path_style: extra.path_style, animated: extra.animated, marker_start: extra.marker_start, marker_end: extra.marker_end }, + data: { type: edgeType, label: extra.label, vlan_id: extra.vlan_id, custom_color: extra.custom_color, path_style: extra.path_style, line_style: extra.line_style, width_mult: extra.width_mult, animated: extra.animated, marker_start: extra.marker_start, marker_end: extra.marker_end }, } return { edges: [...state.edges, newEdge], @@ -822,6 +822,8 @@ export const useCanvasStore = create((set) => ({ type: edgeType, custom_color: applyOpacity(style.color, style.opacity), path_style: style.pathStyle, + line_style: style.lineStyle, + width_mult: style.widthMult, animated: style.animated, marker_start: style.arrowStart, marker_end: style.arrowEnd, @@ -862,6 +864,8 @@ export const useCanvasStore = create((set) => ({ type: edgeType, custom_color: applyOpacity(style.color, style.opacity), path_style: style.pathStyle, + line_style: style.lineStyle, + width_mult: style.widthMult, animated: style.animated, marker_start: style.arrowStart, marker_end: style.arrowEnd, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 9fdddcf..dc5150a 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -156,6 +156,9 @@ export interface NodeData extends Record { export type EdgePathStyle = 'bezier' | 'smooth' +/** How the edge line itself is drawn (independent of color/width). */ +export type EdgeLineStyle = 'solid' | 'dashed' | 'dotted' + /** * Endpoint marker shape for an edge end. `none` = no marker. * Legacy saves stored a boolean (`true` = filled arrow) — coerced via @@ -175,6 +178,10 @@ export interface EdgeData extends Record { speed?: string custom_color?: string path_style?: EdgePathStyle + /** Line render style override. Unset = use the edge type's default preset. */ + line_style?: EdgeLineStyle + /** Stroke-width multiplier (1×–4×) applied to the edge type's base width. */ + width_mult?: number animated?: boolean | 'snake' | 'flow' | 'basic' | 'none' /** Marker shape at the source end. Legacy boolean (`true`=arrow) coerced on read. */ marker_start?: MarkerShape | boolean @@ -267,6 +274,10 @@ export interface EdgeTypeStyle { color: string opacity: number pathStyle: EdgePathStyle + /** Line render style (solid/dashed/dotted). */ + lineStyle: EdgeLineStyle + /** Stroke-width multiplier (1×–4×). */ + widthMult: number animated: 'none' | 'snake' | 'flow' | 'basic' /** Default marker shape at the source end for new edges of this type. */ arrowStart: MarkerShape diff --git a/frontend/src/utils/__tests__/canvasSerializer.test.ts b/frontend/src/utils/__tests__/canvasSerializer.test.ts index dc88d48..5ce0513 100644 --- a/frontend/src/utils/__tests__/canvasSerializer.test.ts +++ b/frontend/src/utils/__tests__/canvasSerializer.test.ts @@ -250,6 +250,19 @@ describe('serializeEdge', () => { expect(result.marker_end).toBe('arrow') }) + it('serializes line style + width multiplier', () => { + const edge = makeRfEdge({ data: { type: 'wifi', line_style: 'dotted', width_mult: 3 } }) + const result = serializeEdge(edge) + expect(result.line_style).toBe('dotted') + expect(result.width_mult).toBe(3) + }) + + it('nulls line style + width multiplier when absent', () => { + const result = serializeEdge(makeRfEdge()) + expect(result.line_style).toBeNull() + expect(result.width_mult).toBeNull() + }) + it('coerces legacy boolean markers to shape strings', () => { const edge = makeRfEdge({ data: { type: 'ethernet', marker_start: true, marker_end: false } }) const result = serializeEdge(edge) diff --git a/frontend/src/utils/__tests__/edgeLineStyle.test.ts b/frontend/src/utils/__tests__/edgeLineStyle.test.ts new file mode 100644 index 0000000..1ed058e --- /dev/null +++ b/frontend/src/utils/__tests__/edgeLineStyle.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest' +import { + EDGE_LINE_STYLES, + EDGE_TYPE_BASE_WIDTH, + EDGE_TYPE_DEFAULT_LINE, + clampWidthMult, + dashArrayFor, +} from '@/utils/edgeLineStyle' +import { EDGE_TYPE_LABELS } from '@/types' + +describe('edgeLineStyle', () => { + it('lists the three render styles', () => { + expect(EDGE_LINE_STYLES).toEqual(['solid', 'dashed', 'dotted']) + }) + + it('has a base width + default line for every edge type', () => { + for (const t of Object.keys(EDGE_TYPE_LABELS) as (keyof typeof EDGE_TYPE_LABELS)[]) { + expect(EDGE_TYPE_BASE_WIDTH[t]).toBeGreaterThan(0) + expect(EDGE_LINE_STYLES).toContain(EDGE_TYPE_DEFAULT_LINE[t]) + } + }) + + it('clampWidthMult keeps values within 1..4 and defaults to 1', () => { + expect(clampWidthMult(undefined)).toBe(1) + expect(clampWidthMult(NaN)).toBe(1) + expect(clampWidthMult(0)).toBe(1) + expect(clampWidthMult(2.5)).toBe(2.5) + expect(clampWidthMult(9)).toBe(4) + }) + + it('dashArrayFor returns undefined for solid and a pattern otherwise', () => { + expect(dashArrayFor('solid', 2)).toBeUndefined() + expect(dashArrayFor('dashed', 2)).toBe('6 4') + expect(dashArrayFor('dotted', 2)).toBe('2 3.6') + }) + + it('dashArrayFor scales with stroke width', () => { + expect(dashArrayFor('dashed', 4)).toBe('12 8') + }) +}) diff --git a/frontend/src/utils/canvasSerializer.ts b/frontend/src/utils/canvasSerializer.ts index 86bc16c..0d3dac9 100644 --- a/frontend/src/utils/canvasSerializer.ts +++ b/frontend/src/utils/canvasSerializer.ts @@ -49,6 +49,8 @@ export interface ApiEdge { speed?: string | null custom_color?: string | null path_style?: string | null + line_style?: string | null + width_mult?: number | null animated?: boolean | 'snake' | 'flow' | 'basic' | 'none' marker_start?: string | boolean | null marker_end?: string | boolean | null @@ -144,6 +146,8 @@ export function serializeEdge(e: Edge): Record { speed: e.data?.speed ?? null, custom_color: e.data?.custom_color ?? null, path_style: e.data?.path_style ?? null, + line_style: e.data?.line_style ?? null, + width_mult: e.data?.width_mult ?? null, animated: e.data?.animated ?? false, marker_start: normalizeMarker(e.data?.marker_start), marker_end: normalizeMarker(e.data?.marker_end), diff --git a/frontend/src/utils/edgeLineStyle.ts b/frontend/src/utils/edgeLineStyle.ts new file mode 100644 index 0000000..c5b674c --- /dev/null +++ b/frontend/src/utils/edgeLineStyle.ts @@ -0,0 +1,55 @@ +import type { EdgeLineStyle, EdgeType } from '@/types' + +/** Line render styles selectable per edge type, in picker order. */ +export const EDGE_LINE_STYLES: EdgeLineStyle[] = ['solid', 'dashed', 'dotted'] + +export const EDGE_LINE_STYLE_LABELS: Record = { + solid: 'Solid', + dashed: 'Dashed', + dotted: 'Dotted', +} + +/** + * Base stroke width (px) per edge type — the "1×" reference the width + * multiplier scales from. Mirrors BASE_STYLES in canvas/edges/index.tsx. + */ +export const EDGE_TYPE_BASE_WIDTH: Record = { + ethernet: 2, + wifi: 1.5, + iot: 1.5, + vlan: 2.5, + virtual: 1, + cluster: 2.5, + fibre: 2.5, + electrical: 2, +} + +/** Default line render style per edge type (matches the hardcoded dash presets). */ +export const EDGE_TYPE_DEFAULT_LINE: Record = { + ethernet: 'solid', + wifi: 'dashed', + iot: 'dotted', + vlan: 'solid', + virtual: 'dashed', + cluster: 'dashed', + fibre: 'solid', + electrical: 'solid', +} + +/** Multiplier is limited to 1×–4× of the type's base width. */ +export function clampWidthMult(v: number | undefined): number { + if (v == null || Number.isNaN(v)) return 1 + return Math.min(4, Math.max(1, v)) +} + +/** + * SVG `stroke-dasharray` for a line style, scaled to the stroke width. + * `solid` returns undefined (no dashes). `dotted` pairs with a round line cap + * so the short segments render as dots. + */ +export function dashArrayFor(style: EdgeLineStyle, strokeWidth: number): string | undefined { + const w = Math.max(0.5, strokeWidth) + if (style === 'dashed') return `${w * 3} ${w * 2}` + if (style === 'dotted') return `${w} ${w * 1.8}` + return undefined +}