Merge pull request #252 from Pouzor/feat/configurable-edge-line-style

feat: configurable edge line style + width per type and per edge
This commit is contained in:
Pouzor - Rémy Jardient
2026-07-05 15:19:54 +02:00
committed by GitHub
19 changed files with 469 additions and 9 deletions
+4
View File
@@ -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):
+2
View File
@@ -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')
+2
View File
@@ -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'
+4
View File
@@ -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
+11
View File
@@ -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")
+25
View File
@@ -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)
@@ -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<EdgeData> = {}) {
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<Edge<EdgeData>>
return render(
<ReactFlowProvider>
<svg>
<HomelableEdge {...props} />
</svg>
</ReactFlowProvider>,
)
}
/** 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('')
})
})
+17 -1
View File
@@ -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)` } : {}),
@@ -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 (
<svg width={width} height={h} className="shrink-0" aria-hidden>
<line
x1={2}
y1={h / 2}
x2={width - 2}
y2={h / 2}
stroke={color}
strokeWidth={strokeWidth}
strokeDasharray={dashArrayFor(lineStyle, strokeWidth)}
strokeLinecap={lineStyle === 'dotted' ? 'round' : 'butt'}
/>
</svg>
)
}
// ── Color + opacity row ──────────────────────────────────────────────────────
interface ColorRowProps {
@@ -248,6 +281,52 @@ function EdgeEditor({ edgeType, style, onChange, onApplyToExisting }: EdgeEditor
</div>
<div className="border-t border-[#30363d] pt-3 flex flex-col gap-3">
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-[#8b949e]">Line style</span>
<EdgeLineSwatch
color={applyOpacity(style.color, style.opacity)}
lineStyle={style.lineStyle}
strokeWidth={EDGE_TYPE_BASE_WIDTH[edgeType] * style.widthMult}
width={72}
/>
</div>
<div className="flex gap-2">
{EDGE_LINE_STYLES.map((ls) => (
<button
key={ls}
type="button"
onClick={() => set('lineStyle', ls)}
className="px-3 py-1 text-xs rounded border transition-colors"
style={{
borderColor: style.lineStyle === ls ? '#00d4ff' : '#30363d',
background: style.lineStyle === ls ? '#00d4ff22' : 'transparent',
color: style.lineStyle === ls ? '#00d4ff' : '#8b949e',
}}
>
{EDGE_LINE_STYLE_LABELS[ls]}
</button>
))}
</div>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-[#8b949e]">Line width</span>
<span className="text-xs text-[#8b949e]">{style.widthMult}×</span>
</div>
<input
type="range"
min={1}
max={4}
step={1}
value={style.widthMult}
onChange={(e) => set('widthMult', clampWidthMult(parseInt(e.target.value, 10)))}
aria-label="Line width multiplier"
className="w-full h-1 accent-[#00d4ff]"
/>
</div>
<div>
<div className="text-xs text-[#8b949e] mb-2">Path style</div>
<div className="flex gap-2">
@@ -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 (
<button
@@ -476,9 +557,10 @@ export function CustomStyleModal({ open, onClose, initialNodeType }: CustomStyle
}}
>
<span className="flex-1 truncate">{EDGE_TYPE_LABELS[t]}</span>
<span
className="w-8 h-1.5 rounded-full shrink-0"
style={{ background: swatchColor }}
<EdgeLineSwatch
color={swatchColor}
lineStyle={lineStyle}
strokeWidth={EDGE_TYPE_BASE_WIDTH[t] * widthMult}
/>
</button>
)
+66 -1
View File
@@ -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<AnimMode>(() => toAnimMode(initial?.animated))
const [markerStart, setMarkerStart] = useState<MarkerShape>(normalizeMarker(initial?.marker_start))
const [markerEnd, setMarkerEnd] = useState<MarkerShape>(normalizeMarker(initial?.marker_end))
// Undefined = follow the edge type's default line preset (live, like color).
const [lineStyle, setLineStyle] = useState<EdgeLineStyle | undefined>(initial?.line_style)
const [widthMult, setWidthMult] = useState<number>(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,
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">Line Style</Label>
<svg width={56} height={12} aria-hidden>
<line
x1={2}
y1={6}
x2={54}
y2={6}
stroke={effectiveColor}
strokeWidth={previewWidth}
strokeDasharray={dashArrayFor(effectiveLineStyle, previewWidth)}
strokeLinecap={effectiveLineStyle === 'dotted' ? 'round' : 'butt'}
/>
</svg>
</div>
<div className={`flex rounded-md overflow-hidden border border-[#30363d] ${modalStyles['modal-interactive']}`}>
{EDGE_LINE_STYLES.map((ls, i) => (
<button
key={ls}
type="button"
onClick={() => setLineStyle(ls)}
className="flex-1 py-1 text-xs transition-colors cursor-pointer"
tabIndex={0}
aria-label={`Line style ${ls}`}
style={{
background: effectiveLineStyle === ls ? '#00d4ff22' : '#21262d',
color: effectiveLineStyle === ls ? '#00d4ff' : '#8b949e',
borderRight: i < EDGE_LINE_STYLES.length - 1 ? '1px solid #30363d' : undefined,
}}
>
{EDGE_LINE_STYLE_LABELS[ls]}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">Line Width</Label>
<span className="text-xs text-muted-foreground">{widthMult}×</span>
</div>
<input
type="range"
min={1}
max={4}
step={1}
value={widthMult}
onChange={(e) => setWidthMult(clampWidthMult(parseInt(e.target.value, 10)))}
aria-label="Line width multiplier"
className="w-full h-1 accent-[#00d4ff]"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Animation</Label>
<div className={`flex rounded-md overflow-hidden border border-[#30363d] ${modalStyles['modal-interactive']}`}>
@@ -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(<CustomStyleModal open onClose={vi.fn()} />)
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(<CustomStyleModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
@@ -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(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
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(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ type: 'wifi' }} />)
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(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
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(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ line_style: 'dashed', width_mult: 3 }} />)
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', () => {
@@ -1320,12 +1320,14 @@ describe('canvasStore — custom style apply', () => {
const e2: Edge<EdgeData> = { 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)
+5 -1
View File
@@ -279,7 +279,7 @@ export const useCanvasStore = create<CanvasState>((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<CanvasState>((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<CanvasState>((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,
+11
View File
@@ -156,6 +156,9 @@ export interface NodeData extends Record<string, unknown> {
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<string, unknown> {
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
@@ -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)
@@ -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')
})
})
+4
View File
@@ -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<EdgeData>): Record<string, unknown> {
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),
+55
View File
@@ -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<EdgeLineStyle, string> = {
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<EdgeType, number> = {
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<EdgeType, EdgeLineStyle> = {
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
}