feat: selectable marker shapes per edge endpoint

Replace the on/off arrowhead toggle with a per-end shape picker. Each end
(start/end) independently selects: none, arrow, arrow-open, circle, diamond,
or square. Markers still recolor live from the resolved stroke color.

Frontend:
- MarkerShape type + edgeMarkers util (normalizeMarker, MARKER_GEOMETRY);
  legacy boolean coerces to 'arrow' on read.
- Per-shape <marker> inner geometry; symmetric shapes use fixed orient.
- MarkerShapePicker reused in EdgeModal and CustomStyleModal.
- Serializer normalizes to shape strings.

Backend:
- Edge marker columns Boolean -> String (default 'none'); TEXT migration.
- normalize_marker() + validators coerce legacy bool / unknown values.

ha-relevant: yes
This commit is contained in:
Pouzor
2026-07-05 11:30:49 +02:00
parent 1cf525844b
commit c95d104245
20 changed files with 385 additions and 157 deletions
@@ -243,17 +243,24 @@ describe('serializeEdge', () => {
expect(result.animated).toBe(true)
})
it('serializes arrow markers', () => {
const edge = makeRfEdge({ data: { type: 'ethernet', marker_start: true, marker_end: true } })
it('serializes endpoint marker shapes', () => {
const edge = makeRfEdge({ data: { type: 'ethernet', marker_start: 'diamond', marker_end: 'arrow' } })
const result = serializeEdge(edge)
expect(result.marker_start).toBe(true)
expect(result.marker_end).toBe(true)
expect(result.marker_start).toBe('diamond')
expect(result.marker_end).toBe('arrow')
})
it('defaults arrow markers to false when absent', () => {
it('coerces legacy boolean markers to shape strings', () => {
const edge = makeRfEdge({ data: { type: 'ethernet', marker_start: true, marker_end: false } })
const result = serializeEdge(edge)
expect(result.marker_start).toBe('arrow')
expect(result.marker_end).toBe('none')
})
it('defaults endpoint markers to "none" when absent', () => {
const result = serializeEdge(makeRfEdge())
expect(result.marker_start).toBe(false)
expect(result.marker_end).toBe(false)
expect(result.marker_start).toBe('none')
expect(result.marker_end).toBe('none')
})
it('nulls optional fields when absent', () => {
+5 -4
View File
@@ -1,6 +1,7 @@
import type { Node, Edge } from '@xyflow/react'
import type { NodeData, EdgeData, Waypoint } from '@/types'
import { normalizeHandle, clampHandles, handleId, handleCountField, type Side } from '@/utils/handleUtils'
import { normalizeMarker } from '@/utils/edgeMarkers'
// ── Types ────────────────────────────────────────────────────────────────────
@@ -49,8 +50,8 @@ export interface ApiEdge {
custom_color?: string | null
path_style?: string | null
animated?: boolean | 'snake' | 'flow' | 'basic' | 'none'
marker_start?: boolean | null
marker_end?: boolean | null
marker_start?: string | boolean | null
marker_end?: string | boolean | null
source_handle?: string | null
target_handle?: string | null
waypoints?: Waypoint[] | null
@@ -144,8 +145,8 @@ export function serializeEdge(e: Edge<EdgeData>): Record<string, unknown> {
custom_color: e.data?.custom_color ?? null,
path_style: e.data?.path_style ?? null,
animated: e.data?.animated ?? false,
marker_start: e.data?.marker_start ?? false,
marker_end: e.data?.marker_end ?? false,
marker_start: normalizeMarker(e.data?.marker_start),
marker_end: normalizeMarker(e.data?.marker_end),
source_handle: normalizeHandle(e.sourceHandle),
target_handle: normalizeHandle(e.targetHandle),
waypoints: e.data?.waypoints?.length ? e.data.waypoints : null,
+42
View File
@@ -0,0 +1,42 @@
import type { MarkerShape } from '@/types'
/** All selectable marker shapes, in picker order. */
export const MARKER_SHAPES: MarkerShape[] = [
'none', 'arrow', 'arrow-open', 'circle', 'diamond', 'square',
]
const SHAPE_SET = new Set<string>(MARKER_SHAPES)
/**
* Coerce any stored/legacy marker value into a MarkerShape.
* - legacy boolean `true` → 'arrow'
* - legacy boolean `false` / null / undefined → 'none'
* - a valid shape string passes through
* - anything unknown → 'none'
*/
export function normalizeMarker(v: unknown): MarkerShape {
if (v === true) return 'arrow'
if (v === false || v == null) return 'none'
if (typeof v === 'string' && SHAPE_SET.has(v)) return v as MarkerShape
return 'none'
}
export type NonNoneMarkerShape = Exclude<MarkerShape, 'none'>
/**
* SVG <marker> geometry per shape, drawn in a 0..10 viewBox.
* - `refX` positions the shape on the endpoint: directional shapes (arrow,
* arrow-open) put their tip on the point; symmetric caps (circle, diamond,
* square) centre on it.
* - `directional` shapes rotate with the edge; symmetric ones don't care.
*/
export const MARKER_GEOMETRY: Record<
NonNoneMarkerShape,
{ refX: number; directional: boolean }
> = {
arrow: { refX: 9, directional: true },
'arrow-open': { refX: 8.5, directional: true },
circle: { refX: 5, directional: false },
diamond: { refX: 5, directional: false },
square: { refX: 5, directional: false },
}