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:
@@ -13,6 +13,7 @@ import type { EdgeData, 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 { buildWaypointPath, getAddWaypointHandlePosition, getWaypointLabelPosition, snap45, snap45both } from './waypointUtils'
|
||||
|
||||
const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149']
|
||||
@@ -22,6 +23,22 @@ function getVlanColor(vlanId?: number): string {
|
||||
return VLAN_COLORS[vlanId % VLAN_COLORS.length]
|
||||
}
|
||||
|
||||
/** Inner SVG element for an edge <marker>, drawn in a 0..10 viewBox. */
|
||||
function markerInnerElement(shape: NonNoneMarkerShape, color: string): React.ReactElement {
|
||||
switch (shape) {
|
||||
case 'arrow':
|
||||
return <path d="M 0 0 L 10 5 L 0 10 z" fill={color} />
|
||||
case 'arrow-open':
|
||||
return <path d="M 1 1 L 9 5 L 1 9" fill="none" stroke={color} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||
case 'circle':
|
||||
return <circle cx={5} cy={5} r={4} fill={color} />
|
||||
case 'diamond':
|
||||
return <path d="M 5 0.5 L 9.5 5 L 5 9.5 L 0.5 5 z" fill={color} />
|
||||
case 'square':
|
||||
return <rect x={1} y={1} width={8} height={8} fill={color} />
|
||||
}
|
||||
}
|
||||
|
||||
// ── Waypoint drag handle ─────────────────────────────────────────────────────
|
||||
|
||||
interface WaypointHandleProps {
|
||||
@@ -351,37 +368,43 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle
|
||||
? segmentMidpoints(sourceX, sourceY, waypoints, targetX, targetY, pathStyle, sourcePosition)
|
||||
: []
|
||||
|
||||
// ── Arrowheads ─────────────────────────────────────────────────────────────
|
||||
// ── Endpoint markers ───────────────────────────────────────────────────────
|
||||
// Custom inline <marker> defs filled with the live strokeColor so they recolor
|
||||
// reactively (custom_color / vlan / selected). Sized from the stroke width.
|
||||
const markerStart = data?.marker_start === true
|
||||
const markerEnd = data?.marker_end === true
|
||||
// Each end picks its own shape (arrow / arrow-open / circle / diamond / square)
|
||||
// independently; 'none' renders no marker.
|
||||
const startShape = normalizeMarker(data?.marker_start)
|
||||
const endShape = normalizeMarker(data?.marker_end)
|
||||
const hasMarkers = startShape !== 'none' || endShape !== 'none'
|
||||
const strokeW = (style.strokeWidth as number) ?? 2
|
||||
const markerSize = 6 + strokeW * 2
|
||||
const startMarkerId = `arrow-start-${id}`
|
||||
const endMarkerId = `arrow-end-${id}`
|
||||
|
||||
const arrowMarker = (markerId: string, orient: string) => (
|
||||
<marker
|
||||
id={markerId}
|
||||
viewBox="0 0 10 10"
|
||||
refX={9}
|
||||
refY={5}
|
||||
markerWidth={markerSize}
|
||||
markerHeight={markerSize}
|
||||
markerUnits="userSpaceOnUse"
|
||||
orient={orient}
|
||||
>
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill={strokeColor} />
|
||||
</marker>
|
||||
)
|
||||
const arrowMarker = (markerId: string, shape: NonNoneMarkerShape, orient: string) => {
|
||||
const geo = MARKER_GEOMETRY[shape]
|
||||
return (
|
||||
<marker
|
||||
id={markerId}
|
||||
viewBox="0 0 10 10"
|
||||
refX={geo.refX}
|
||||
refY={5}
|
||||
markerWidth={markerSize}
|
||||
markerHeight={markerSize}
|
||||
markerUnits="userSpaceOnUse"
|
||||
orient={geo.directional ? orient : '0'}
|
||||
>
|
||||
{markerInnerElement(shape, strokeColor)}
|
||||
</marker>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{(markerStart || markerEnd) && (
|
||||
{hasMarkers && (
|
||||
<defs>
|
||||
{markerStart && arrowMarker(startMarkerId, 'auto-start-reverse')}
|
||||
{markerEnd && arrowMarker(endMarkerId, 'auto')}
|
||||
{startShape !== 'none' && arrowMarker(startMarkerId, startShape, 'auto-start-reverse')}
|
||||
{endShape !== 'none' && arrowMarker(endMarkerId, endShape, 'auto')}
|
||||
</defs>
|
||||
)}
|
||||
|
||||
@@ -390,8 +413,8 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle
|
||||
path={edgePath}
|
||||
style={animMode === 'basic' ? { ...style, stroke: 'transparent' } : style}
|
||||
interactionWidth={16}
|
||||
markerStart={markerStart ? `url(#${startMarkerId})` : undefined}
|
||||
markerEnd={markerEnd ? `url(#${endMarkerId})` : undefined}
|
||||
markerStart={startShape !== 'none' ? `url(#${startMarkerId})` : undefined}
|
||||
markerEnd={endShape !== 'none' ? `url(#${endMarkerId})` : undefined}
|
||||
/>
|
||||
|
||||
{animMode === 'basic' && (
|
||||
|
||||
Reference in New Issue
Block a user