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:
@@ -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):
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user