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
@@ -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
}