Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { Point } from '~/utils/edge-label-utils'

type EdgeLabelProperties = {
position: Point
text: string
selected?: boolean
onDelete: () => void
}

export default function EdgeLabel({ position, text, selected, onDelete }: Readonly<EdgeLabelProperties>) {
return (
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${position.x}px,${position.y}px)`,
pointerEvents: 'all',
zIndex: 20,
}}
className="nodrag flex flex-col items-center"
>
<p className="bg-background border-border relative rounded-md border p-1 px-2 text-sm">
{text}
{selected && (
<button
className="text-foreground absolute -top-3 -right-2.5 rounded-full border border-black shadow-sm hover:border-red-400 hover:text-red-400"
onClick={onDelete}
>
<svg width="15" height="15" viewBox="0 0 15 15" stroke="currentColor" strokeLinecap="round">
<line x1="5" y1="5" x2="10" y2="10" />
<line x1="5" y1="10" x2="10" y2="5" />
</svg>
</button>
)}
</p>
</div>
)
}
40 changes: 16 additions & 24 deletions src/main/frontend/app/routes/studio/canvas/edgetypes/frank-edge.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { BaseEdge, EdgeLabelRenderer, getBezierPath, type Position } from '@xyflow/react'
import useFlowStore from '~/stores/flow-store'
import { getEdgeLabelPositions } from '~/utils/edge-label-utils'
import EdgeLabel from './edge-label'

export type FrankEdgeProperties = {
id: string
Expand Down Expand Up @@ -59,34 +61,24 @@ export default function FrankEdge({
targetPosition,
})

const labelPositions = getEdgeLabelPositions(
{ sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition },
{ x: labelX, y: labelY },
)

return (
<>
<BaseEdge id={id} path={edgePath} style={{ strokeWidth: 3 }} />
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: 'all',
zIndex: 20,
}}
className="nodrag flex flex-col items-center"
>
<p className="bg-background border-border relative rounded-md border p-1 px-2 text-sm">
{sourceHandleType}
{selected && (
<button
className="text-foreground absolute -top-3 -right-2.5 rounded-full border border-black shadow-sm hover:border-red-400 hover:text-red-400"
onClick={() => deleteEdge(id)}
>
<svg width="15" height="15" viewBox="0 0 15 15" stroke="currentColor" strokeLinecap="round">
<line x1="5" y1="5" x2="10" y2="10" />
<line x1="5" y1="10" x2="10" y2="5" />
</svg>
</button>
)}
</p>
</div>
{labelPositions.map((position) => (
<EdgeLabel
key={`${position.x},${position.y}`}
position={position}
text={sourceHandleType}
selected={selected}
onDelete={() => deleteEdge(id)}
/>
))}
</EdgeLabelRenderer>
</>
)
Expand Down
69 changes: 69 additions & 0 deletions src/main/frontend/app/utils/edge-label-utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import {
getEdgeLabelPositions,
getEdgeLength,
getPointOnBezierEdge,
SHORT_EDGE_LABEL_THRESHOLD,
} from './edge-label-utils'

const horizontal = (length: number) => ({
sourceX: 0,
sourceY: 0,
targetX: length,
targetY: 0,
sourcePosition: 'right',
targetPosition: 'left',
})

describe('getEdgeLength', () => {
it('returns the Pythagorean distance between the handles', () => {
expect(getEdgeLength({ sourceX: 0, sourceY: 0, targetX: 3, targetY: 4 })).toBe(5)
})

it('returns 0 for coincident handles', () => {
expect(getEdgeLength({ sourceX: 10, sourceY: 20, targetX: 10, targetY: 20 })).toBe(0)
})
})

describe('getPointOnBezierEdge', () => {
const geometry = {
sourceX: 0,
sourceY: 0,
targetX: 200,
targetY: 0,
sourcePosition: 'right',
targetPosition: 'left',
}

it('returns the source handle at t = 0', () => {
expect(getPointOnBezierEdge(geometry, 0)).toEqual({ x: 0, y: 0 })
})

it('returns the target handle at t = 1', () => {
expect(getPointOnBezierEdge(geometry, 1)).toEqual({ x: 200, y: 0 })
})

it('returns the midpoint at t = 0.5 for a symmetric horizontal edge', () => {
expect(getPointOnBezierEdge(geometry, 0.5)).toEqual({ x: 100, y: 0 })
})
})

describe('getEdgeLabelPositions', () => {
const center = { x: 50, y: 50 }

it('keeps a single centred label for forwards shorter than the threshold', () => {
const positions = getEdgeLabelPositions(horizontal(SHORT_EDGE_LABEL_THRESHOLD - 1), center)

expect(positions).toEqual([center])
})

it('splits into two labels for forwards at or above the threshold', () => {
const length = SHORT_EDGE_LABEL_THRESHOLD + 20
const positions = getEdgeLabelPositions(horizontal(length), center)

expect(positions).toHaveLength(2)
const [start, end] = positions
expect(start.x).toBeLessThan(length / 2)
expect(end.x).toBeGreaterThan(length / 2)
expect(start.x + end.x).toBeCloseTo(length)
})
})
90 changes: 90 additions & 0 deletions src/main/frontend/app/utils/edge-label-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
export type Point = { x: number; y: number }

type CardinalPosition = 'left' | 'right' | 'top' | 'bottom'

export type BezierEdgeGeometry = {
sourceX: number
sourceY: number
targetX: number
targetY: number
sourcePosition?: string
targetPosition?: string
curvature?: number
}

export const SHORT_EDGE_LABEL_THRESHOLD = 600
export const EDGE_LABEL_END_OFFSET = 150

export function getEdgeLength({ sourceX, sourceY, targetX, targetY }: BezierEdgeGeometry): number {
return Math.hypot(targetX - sourceX, targetY - sourceY)
}

function toCardinalPosition(position: string | undefined, fallback: CardinalPosition): CardinalPosition {
return position === 'left' || position === 'right' || position === 'top' || position === 'bottom'
? position
: fallback
}

function getBezierControlOffset(distance: number, curvature: number): number {
return distance >= 0 ? 0.5 * distance : curvature * 25 * Math.sqrt(-distance)
}

function getBezierControlPoint(
position: CardinalPosition,
x1: number,
y1: number,
x2: number,
y2: number,
curvature: number,
): Point {
switch (position) {
case 'left': {
return { x: x1 - getBezierControlOffset(x1 - x2, curvature), y: y1 }
}
case 'right': {
return { x: x1 + getBezierControlOffset(x2 - x1, curvature), y: y1 }
}
case 'top': {
return { x: x1, y: y1 - getBezierControlOffset(y1 - y2, curvature) }
}
case 'bottom': {
return { x: x1, y: y1 + getBezierControlOffset(y2 - y1, curvature) }
}
}
}

export function getPointOnBezierEdge(geometry: BezierEdgeGeometry, t: number): Point {
const { sourceX, sourceY, targetX, targetY, curvature = 0.25 } = geometry
const sourcePosition = toCardinalPosition(geometry.sourcePosition, 'bottom')
const targetPosition = toCardinalPosition(geometry.targetPosition, 'top')

const sourceControl = getBezierControlPoint(sourcePosition, sourceX, sourceY, targetX, targetY, curvature)
const targetControl = getBezierControlPoint(targetPosition, targetX, targetY, sourceX, sourceY, curvature)

const mt = 1 - t
const a = mt * mt * mt
const b = 3 * mt * mt * t
const c = 3 * mt * t * t
const d = t * t * t

return {
x: a * sourceX + b * sourceControl.x + c * targetControl.x + d * targetX,
y: a * sourceY + b * sourceControl.y + c * targetControl.y + d * targetY,
}
}

/**
* Decides where a forward's label(s) go. Short forwards keep a single label at `center`; longer ones
* split into two labels sitting a fixed distance after the source circle and before the target circle,
* so labels stay readable without pan-zooming.
*/
export function getEdgeLabelPositions(geometry: BezierEdgeGeometry, center: Point): Point[] {
const length = getEdgeLength(geometry)

if (length < SHORT_EDGE_LABEL_THRESHOLD) {
return [center]
}

const t = Math.min(0.5, EDGE_LABEL_END_OFFSET / length)
return [getPointOnBezierEdge(geometry, t), getPointOnBezierEdge(geometry, 1 - t)]
}
Loading