feat: configurable edge line style + width per type and per edge
Edge render (solid/dashed/dotted) and stroke width were hardcoded per edge type. Expose both as user settings. - Custom Style modal (Edges): line-style buttons, 1-4x width slider, live preview; left-list swatch renders the actual line. - Per-edge EdgeModal: same controls; line style follows the type preset live until overridden. - Renderer applies line_style/width_mult over BASE_STYLES (width scales markers + animation overlays); unset keeps the type default look. - Persist line_style/width_mult through serializer, canvas save, and the edges API (new nullable columns, idempotent migration). ha-relevant: yes
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user