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