import { CSSProperties, ReactElement, RefObject, useCallback, useEffect, useLayoutEffect, useRef, useState, MouseEvent as ReactMouseEvent, } from "react" import { add, angle, middle, distance, middlePos, minus, mul, Pos, posWithinBase, ratioWithinBase, relativeTo, norm, } from "../../geo/Pos" import "../../style/bendable_arrows.css" import Draggable from "react-draggable" export interface BendableArrowProps { area: RefObject startPos: Pos segments: Segment[] onSegmentsChanges: (edges: Segment[]) => void forceStraight: boolean wavy: boolean startRadius?: number endRadius?: number onDeleteRequested?: () => void style?: ArrowStyle } export interface ArrowStyle { width?: number dashArray?: string head?: () => ReactElement tail?: () => ReactElement } const ArrowStyleDefaults: ArrowStyle = { width: 3, } export interface Segment { next: Pos controlPoint?: Pos } /** * Given a circle shaped by a central position, and a radius, return * a position that is constrained on its perimeter, pointing to the direction * between the circle's center and the reference position. * @param center circle's center. * @param reference a reference point used to create the angle where the returned position * will point to on the circle's perimeter * @param radius circle's radius. */ function constraintInCircle(center: Pos, reference: Pos, radius: number): Pos { const theta = angle(center, reference) return { x: center.x - Math.sin(theta) * radius, y: center.y - Math.cos(theta) * radius, } } /** * An arrow that follows a bézier curve built from given segments that can be edited, added or removed by the user * The arrow only works with relative positions within a given area. * All position handled by the arrow must be positions where x and y are a percentage within the area's surface * (0.5, 0.5) is a position at the middle of the area * (1, 0.75) means that the position is at 100percent to the right of given area, and 75 percent to the bottom * @param area * @param startPos * @param segments * @param onSegmentsChanges * @param wavy * @param forceStraight * @param style * @param startRadius * @param endRadius * @param onDeleteRequested * @constructor */ export default function BendableArrow({ area, startPos, segments, onSegmentsChanges, forceStraight, wavy, style, startRadius = 0, endRadius = 0, onDeleteRequested, }: BendableArrowProps) { const containerRef = useRef(null) const svgRef = useRef(null) const pathRef = useRef(null) const styleWidth = style?.width ?? ArrowStyleDefaults.width const computeInternalSegments = useCallback( (segments: Segment[]) => { return segments.map((segment, idx) => { if (idx == 0) { return { start: startPos, controlPoint: segment.controlPoint ?? null, end: segment.next, } } const start = segments[idx - 1].next return { start, controlPoint: segment.controlPoint ?? null, end: segment.next, } }) }, [segments, startPos], ) // Cache the segments so that when the user is changing the segments (it moves an ArrowPoint), // it does not unwind to this arrow's component parent until validated. // The changes are validated (meaning that onSegmentsChanges is called) when the // user releases an ArrowPoint. const [internalSegments, setInternalSegments] = useState( () => computeInternalSegments(segments), ) // If the (original) segments changes, overwrite the current ones. useLayoutEffect(() => { setInternalSegments(computeInternalSegments(segments)) }, [startPos, segments, computeInternalSegments]) const [isSelected, setIsSelected] = useState(false) const headRef = useRef(null) const tailRef = useRef(null) /** * Computes and return the segments edition points * @param parentBase */ function computePoints(parentBase: DOMRect) { return segments.flatMap(({ next, controlPoint }, i) => { const prev = i == 0 ? startPos : segments[i - 1].next const prevRelative = posWithinBase(prev, parentBase) const nextRelative = posWithinBase(next, parentBase) const cpPos = controlPoint || ratioWithinBase( add(middle(prevRelative, nextRelative), parentBase), parentBase, ) const setControlPointPos = (newPos: Pos | null) => { const segment = segments[i] const newSegments = segments.toSpliced(i, 1, { ...segment, controlPoint: newPos ?? undefined, }) onSegmentsChanges(newSegments) } return [ // curve control point setControlPointPos(null)} onMoves={(controlPoint) => { setInternalSegments((is) => { return is.toSpliced(i, 1, { ...is[i], controlPoint, }) }) }} />, //next pos point (only if this is not the last segment) i != segments.length - 1 && ( { const currentSegment = segments[i] const newSegments = segments.toSpliced(i, 1, { ...currentSegment, next, }) onSegmentsChanges(newSegments) }} onRemove={() => { onSegmentsChanges( segments.toSpliced(Math.max(i - 1, 0), 1), ) }} onMoves={(end) => { setInternalSegments((is) => { return is.toSpliced( i, 2, { ...is[i], end, }, { ...is[i + 1], start: end, }, ) }) }} /> ), ] }) } /** * Updates the states based on given parameters, which causes the arrow to re-render. */ const update = useCallback(() => { const parentBase = area.current!.getBoundingClientRect() const segment = internalSegments[0] ?? null if (segment == null) throw new Error("segments might not be empty.") const lastSegment = internalSegments[internalSegments.length - 1] const startRelative = posWithinBase(startPos, parentBase) const endRelative = posWithinBase(lastSegment.end, parentBase) const startNext = segment.controlPoint && !forceStraight ? posWithinBase(segment.controlPoint, parentBase) : posWithinBase(segment.end, parentBase) const endPrevious = forceStraight ? startRelative : lastSegment.controlPoint ? posWithinBase(lastSegment.controlPoint, parentBase) : posWithinBase(lastSegment.start, parentBase) const tailPos = constraintInCircle( startRelative, startNext, startRadius!, ) const headPos = constraintInCircle(endRelative, endPrevious, endRadius!) const left = Math.min(tailPos.x, headPos.x) const top = Math.min(tailPos.y, headPos.y) Object.assign(tailRef.current!.style, { left: tailPos.x + "px", top: tailPos.y + "px", transformOrigin: "top center", transform: `translateX(-50%) rotate(${ -angle(tailPos, startNext) * (180 / Math.PI) }deg)`, } as CSSProperties) Object.assign(headRef.current!.style, { left: headPos.x + "px", top: headPos.y + "px", transformOrigin: "top center", transform: `translateX(-50%) rotate(${ -angle(headPos, endPrevious) * (180 / Math.PI) }deg)`, } as CSSProperties) const svgStyle: CSSProperties = { left: left + "px", top: top + "px", } const segmentsRelatives = ( forceStraight ? [ { start: startPos, controlPoint: undefined, end: lastSegment.end, }, ] : internalSegments ).map(({ start, controlPoint, end }, idx) => { const svgPosRelativeToBase = { x: left, y: top } const nextRelative = relativeTo( posWithinBase(end, parentBase), svgPosRelativeToBase, ) const startRelative = relativeTo( posWithinBase(start, parentBase), svgPosRelativeToBase, ) const controlPointRelative = controlPoint && !forceStraight ? relativeTo( posWithinBase(controlPoint, parentBase), svgPosRelativeToBase, ) : middle(startRelative, nextRelative) return { start: startRelative, end: nextRelative, cp: controlPointRelative, } }) const computedSegments = segmentsRelatives .map(({ start, cp, end: e }, idx) => { let end = e if (idx == segmentsRelatives.length - 1) { //if it is the last element end = constraintInCircle(end, cp, endRadius!) } const previousSegment = idx != 0 ? segmentsRelatives[idx - 1] : undefined const previousSegmentCpAndCurrentPosVector = minus( start, previousSegment?.cp ?? middle(start, end), ) const smoothCp = previousSegment ? add(start, previousSegmentCpAndCurrentPosVector) : cp if (wavy) { return wavyBezier(start, smoothCp, cp, end, 10, 10) } if (forceStraight) { return `L${end.x} ${end.y}` } return `C${smoothCp.x} ${smoothCp.y}, ${cp.x} ${cp.y}, ${end.x} ${end.y}` }) .join(" ") const d = `M${tailPos.x - left} ${tailPos.y - top} ` + computedSegments pathRef.current!.setAttribute("d", d) Object.assign(svgRef.current!.style, svgStyle) }, [ startPos, internalSegments, forceStraight, startRadius, endRadius, style, ]) // Will update the arrow when the props change useEffect(update, [update]) // Adds a selection handler // Also force an update when the window is resized useEffect(() => { const selectionHandler = (e: MouseEvent) => { if (!(e.target instanceof Node)) return // The arrow is selected if the mouse clicks on an element that belongs to the current arrow const isSelected = containerRef.current!.contains(e.target) setIsSelected(isSelected) } document.addEventListener("mousedown", selectionHandler) window.addEventListener("resize", update) return () => { document.removeEventListener("mousedown", selectionHandler) window.removeEventListener("resize", update) } }, [update, containerRef]) const addSegment = useCallback( (e: ReactMouseEvent) => { if (forceStraight) return const parentBase = area.current!.getBoundingClientRect() const clickAbsolutePos: Pos = { x: e.pageX, y: e.pageY } const clickPosBaseRatio = ratioWithinBase( clickAbsolutePos, parentBase, ) let segmentInsertionIndex = -1 let segmentInsertionIsOnRightOfCP = false for (let i = 0; i < segments.length; i++) { const segment = segments[i] const beforeSegment = i != 0 ? segments[i - 1] : undefined const beforeSegmentPos = i > 1 ? segments[i - 2].next : startPos const currentPos = beforeSegment ? beforeSegment.next : startPos const nextPos = segment.next const segmentCp = segment.controlPoint ? segment.controlPoint : middle(currentPos, nextPos) const smoothCp = beforeSegment ? add( currentPos, minus( currentPos, beforeSegment.controlPoint ?? middle(beforeSegmentPos, currentPos), ), ) : segmentCp const result = searchOnSegment( currentPos, smoothCp, segmentCp, nextPos, clickPosBaseRatio, 0.05, ) if (result == PointSegmentSearchResult.NOT_FOUND) continue segmentInsertionIndex = i segmentInsertionIsOnRightOfCP = result == PointSegmentSearchResult.RIGHT_TO_CONTROL_POINT break } if (segmentInsertionIndex == -1) return const splicedSegment: Segment = segments[segmentInsertionIndex] onSegmentsChanges( segments.toSpliced( segmentInsertionIndex, 1, { next: clickPosBaseRatio, controlPoint: segmentInsertionIsOnRightOfCP ? splicedSegment.controlPoint : undefined, }, { next: splicedSegment.next, controlPoint: segmentInsertionIsOnRightOfCP ? undefined : splicedSegment.controlPoint, }, ), ) }, [area, forceStraight, onSegmentsChanges, segments, startPos], ) return (
{ if (onDeleteRequested && e.key == "Delete") onDeleteRequested() }} />
{style?.head?.call(style)}
{style?.tail?.call(style)}
{!forceStraight && isSelected && computePoints(area.current!.getBoundingClientRect())}
) } interface ControlPointProps { className: string posRatio: Pos parentBase: DOMRect onMoves: (currentPos: Pos) => void onPosValidated: (newPos: Pos) => void onRemove: () => void radius?: number } enum PointSegmentSearchResult { LEFT_TO_CONTROL_POINT, RIGHT_TO_CONTROL_POINT, NOT_FOUND, } interface FullSegment { start: Pos controlPoint: Pos | null end: Pos } /** * returns a path delimiter that follows a given cubic béziers curve, but with additional waves on it, of the given * density and amplitude. * @param start * @param cp1 * @param cp2 * @param end * @param wavesPer100px * @param amplitude */ function wavyBezier( start: Pos, cp1: Pos, cp2: Pos, end: Pos, wavesPer100px: number, amplitude: number, ): string { function getVerticalAmplification(t: number): Pos { const velocity = cubicBeziersDerivative(start, cp1, cp2, end, t) const velocityLength = norm(velocity) //rotate the velocity by 90 deg const projection = { x: velocity.y, y: -velocity.x } return { x: (projection.x / velocityLength) * amplitude, y: (projection.y / velocityLength) * amplitude, } } let result: string = "" const dist = distance(start, cp1) + distance(cp1, cp2) + distance(cp2, end) // we need two phases in order to complete a wave const waveLength = (dist / 100) * wavesPer100px * 2 const step = 1 / waveLength // 0 : middle to up // 1 : up to middle // 2 : middle to down // 3 : down to middle let phase = 0 for (let t = step; t <= 1; ) { const pos = cubicBeziers(start, cp1, cp2, end, t) const amplification = getVerticalAmplification(t) let nextPos if (phase == 1 || phase == 3) { nextPos = pos } else { if (phase == 0) { nextPos = add(pos, amplification) } else { nextPos = minus(pos, amplification) } } const controlPointBase: Pos = cubicBeziers( start, cp1, cp2, end, t - step / 2, ) const controlPoint: Pos = phase == 0 || phase == 1 ? add(controlPointBase, amplification) : minus(controlPointBase, amplification) result += `Q${controlPoint.x} ${controlPoint.y} ${nextPos.x} ${nextPos.y}` phase = (phase + 1) % 4 t += step if (t < 1 && t > 1 - step) t = 1 } return result } function cubicBeziersDerivative( start: Pos, cp1: Pos, cp2: Pos, end: Pos, t: number, ): Pos { return add( add( mul(minus(cp1, start), 3 * (1 - t) ** 2), mul(minus(cp2, cp1), 6 * (1 - t) * t), ), mul(minus(end, cp2), 3 * t ** 2), ) } function cubicBeziers( start: Pos, cp1: Pos, cp2: Pos, end: Pos, t: number, ): Pos { return add( add( add(mul(start, (1 - t) ** 3), mul(cp1, 3 * t * (1 - t) ** 2)), mul(cp2, 3 * t ** 2 * (1 - t)), ), mul(end, t ** 3), ) } /** * Given a quadratic bézier curve (start position, end position and a middle control point position) * search if the given `point` lies on the curve, within a minimum acceptance distance. * @param start * @param cp1 * @param cp2 * @param end * @param point * @param minDistance */ function searchOnSegment( start: Pos, cp1: Pos, cp2: Pos, end: Pos, point: Pos, minDistance: number, ): PointSegmentSearchResult { const dist = distance(start, cp1) + distance(cp1, cp2) + distance(cp2, end) const step = 1 / (dist / minDistance) function getDistanceAt(t: number): number { return distance(cubicBeziers(start, cp1, cp2, end, t), point) } for (let t = 0; t < 1; t += step) { const distance = getDistanceAt(t) if (distance <= minDistance) return t >= 0.5 ? PointSegmentSearchResult.RIGHT_TO_CONTROL_POINT : PointSegmentSearchResult.LEFT_TO_CONTROL_POINT } return PointSegmentSearchResult.NOT_FOUND } /** * An arrow point, that can be moved. * @param className * @param posRatio * @param parentBase * @param onMoves * @param onPosValidated * @param onRemove * @param radius * @constructor */ function ArrowPoint({ className, posRatio, parentBase, onMoves, onPosValidated, onRemove, radius = 7, }: ControlPointProps) { const ref = useRef(null) const pos = posWithinBase(posRatio, parentBase) return ( { const pointPos = middlePos(ref.current!.getBoundingClientRect()) onPosValidated(ratioWithinBase(pointPos, parentBase)) }} onDrag={() => { const pointPos = middlePos(ref.current!.getBoundingClientRect()) onMoves(ratioWithinBase(pointPos, parentBase)) }} position={{ x: pos.x - radius, y: pos.y - radius }}>
{ if (e.key == "Delete") { onRemove() } }} tabIndex={0} /> ) }