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:
Pouzor
2026-07-05 14:11:01 +02:00
parent 40ec26ab7e
commit 485d2f2b04
19 changed files with 469 additions and 9 deletions
@@ -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('')
})
})