diff --git a/src/main/frontend/app/routes/studio/canvas/edgetypes/edge-label.tsx b/src/main/frontend/app/routes/studio/canvas/edgetypes/edge-label.tsx new file mode 100644 index 00000000..9ee1d854 --- /dev/null +++ b/src/main/frontend/app/routes/studio/canvas/edgetypes/edge-label.tsx @@ -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) { + return ( +
+

+ {text} + {selected && ( + + )} +

+
+ ) +} diff --git a/src/main/frontend/app/routes/studio/canvas/edgetypes/frank-edge.tsx b/src/main/frontend/app/routes/studio/canvas/edgetypes/frank-edge.tsx index c17de12b..5df3a138 100644 --- a/src/main/frontend/app/routes/studio/canvas/edgetypes/frank-edge.tsx +++ b/src/main/frontend/app/routes/studio/canvas/edgetypes/frank-edge.tsx @@ -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 @@ -59,34 +61,24 @@ export default function FrankEdge({ targetPosition, }) + const labelPositions = getEdgeLabelPositions( + { sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition }, + { x: labelX, y: labelY }, + ) + return ( <> -
-

- {sourceHandleType} - {selected && ( - - )} -

-
+ {labelPositions.map((position) => ( + deleteEdge(id)} + /> + ))}
) diff --git a/src/main/frontend/app/utils/edge-label-utils.spec.ts b/src/main/frontend/app/utils/edge-label-utils.spec.ts new file mode 100644 index 00000000..0600ec31 --- /dev/null +++ b/src/main/frontend/app/utils/edge-label-utils.spec.ts @@ -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) + }) +}) diff --git a/src/main/frontend/app/utils/edge-label-utils.ts b/src/main/frontend/app/utils/edge-label-utils.ts new file mode 100644 index 00000000..5009cdf3 --- /dev/null +++ b/src/main/frontend/app/utils/edge-label-utils.ts @@ -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)] +}