You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
Application-Web/front/components/arrows/BendableArrow.tsx

758 lines
23 KiB

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<HTMLElement>
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<HTMLDivElement>(null)
const svgRef = useRef<SVGSVGElement>(null)
const pathRef = useRef<SVGPathElement>(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<FullSegment[]>(
() => computeInternalSegments(segments),
)
// If the (original) segments changes, overwrite the current ones.
useLayoutEffect(() => {
setInternalSegments(computeInternalSegments(segments))
}, [computeInternalSegments])
const [isSelected, setIsSelected] = useState(false)
const headRef = useRef<HTMLDivElement>(null)
const tailRef = useRef<HTMLDivElement>(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
<ArrowPoint
key={i}
className={"arrow-point-control"}
posRatio={cpPos}
parentBase={parentBase}
onPosValidated={setControlPointPos}
onRemove={() => 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 && (
<ArrowPoint
key={i + "-2"}
className={"arrow-point-next"}
posRatio={next}
parentBase={parentBase}
onPosValidated={(next) => {
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 (
<div
ref={containerRef}
style={{ position: "absolute", top: 0, left: 0 }}>
<svg
ref={svgRef}
style={{
overflow: "visible",
position: "absolute",
pointerEvents: "none",
}}>
<path
className="arrow-path"
ref={pathRef}
stroke={"#000"}
strokeWidth={styleWidth}
strokeDasharray={
style?.dashArray ?? ArrowStyleDefaults.dashArray
}
fill="none"
tabIndex={0}
onDoubleClick={addSegment}
onKeyUp={(e) => {
if (onDeleteRequested && e.key == "Delete")
onDeleteRequested()
}}
/>
</svg>
<div
className={"arrow-head"}
style={{ position: "absolute", transformOrigin: "center" }}
ref={headRef}>
{style?.head?.call(style)}
</div>
<div
className={"arrow-tail"}
style={{ position: "absolute", transformOrigin: "center" }}
ref={tailRef}>
{style?.tail?.call(style)}
</div>
{!forceStraight &&
isSelected &&
computePoints(area.current!.getBoundingClientRect())}
</div>
)
}
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<HTMLDivElement>(null)
const pos = posWithinBase(posRatio, parentBase)
return (
<Draggable
nodeRef={ref}
onStop={() => {
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 }}>
<div
ref={ref}
className={`arrow-point ${className}`}
style={{
position: "absolute",
width: radius * 2,
height: radius * 2,
}}
onKeyUp={(e) => {
if (e.key == "Delete") {
onRemove()
}
}}
tabIndex={0}
/>
</Draggable>
)
}