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

329 lines
9.4 KiB

import {
CSSProperties,
ReactElement,
useCallback,
useEffect,
useRef,
useState,
} from "react"
import { angle, middlePos, Pos, relativeTo } from "./Pos"
import "../../style/bendable_arrows.css"
import Draggable from "react-draggable"
export interface BendableArrowProps {
startPos: Pos
segments: Segment[]
onSegmentsChanges: (edges: Segment[]) => void
startRadius?: number
endRadius?: number
style?: ArrowStyle
}
export interface ArrowStyle {
width?: number
head?: () => ReactElement
tail?: () => ReactElement
}
const ArrowStyleDefaults = {
width: 4,
}
export interface Segment {
next: Pos
controlPoint?: Pos
}
function constraintInCircle(pos: Pos, from: Pos, radius: number): Pos {
const theta = angle(pos, from)
return {
x: pos.x - Math.sin(theta) * radius,
y: pos.y - Math.cos(theta) * radius,
}
}
function Triangle({ fill }: { fill: string }) {
return (
<svg viewBox={"0 0 50 50"} width={20} height={20}>
<polygon points={"50 0, 0 0, 25 40"} fill={fill} />
</svg>
)
}
export default function BendableArrow({
startPos,
segments,
onSegmentsChanges,
style,
startRadius = 0,
endRadius = 0,
}: BendableArrowProps) {
const containerRef = useRef<HTMLDivElement>(null)
const svgRef = useRef<SVGSVGElement>(null)
const pathRef = useRef<SVGPathElement>(null)
const styleWidth = style?.width ?? ArrowStyleDefaults.width
const [controlPointsDots, setControlPointsDots] = useState<ReactElement[]>(
[],
)
useEffect(() => {
setInternalSegments(segments)
}, [segments])
const [internalSegments, setInternalSegments] = useState(segments)
const [isSelected, setIsSelected] = useState(false)
const headRef = useRef<HTMLDivElement>(null)
const tailRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const basePos =
containerRef.current!.parentElement!.getBoundingClientRect()
setControlPointsDots(computeControlPoints(basePos))
const selectionHandler = (e: MouseEvent) => {
if (!(e.target instanceof Node)) return
const isSelected = containerRef.current!.contains(e.target)
setIsSelected(isSelected)
}
document.addEventListener("mousedown", selectionHandler)
return () => document.removeEventListener("mousedown", selectionHandler)
}, [])
function computeControlPoints(basePos: Pos) {
return internalSegments.map(({ next, controlPoint }, i) => {
const prev = i == 0 ? startPos : internalSegments[i - 1].next
const prevRelative = relativeTo(prev, basePos)
const nextRelative = relativeTo(next, basePos)
const cpPos = controlPoint
? relativeTo(controlPoint, basePos)
: {
x: prevRelative.x / 2 + nextRelative.x / 2,
y: prevRelative.y / 2 + nextRelative.y / 2,
}
return (
<ControlPoint
key={i}
pos={cpPos}
basePos={basePos}
onPosValidated={(controlPoint) => {
const segment = internalSegments[i]
const segments = internalSegments.toSpliced(i, 1, {
...segment,
controlPoint,
})
onSegmentsChanges(segments)
}}
onMoves={(controlPoint) => {
setInternalSegments((is) => {
return is.toSpliced(i, 1, {
...is[i],
controlPoint,
})
})
}}
/>
)
})
}
const update = useCallback(() => {
// only one segment is supported for now, which is the first.
// any other segments will be ignored
const segment = internalSegments[0] ?? null
if (segment == null) throw new Error("segments might not be empty.")
const endPos = segment.next
const basePos =
containerRef.current!.parentElement!.getBoundingClientRect()
const startRelative = relativeTo(startPos, basePos)
const endRelative = relativeTo(endPos!, basePos)
const controlPoint = segment.controlPoint
? relativeTo(segment.controlPoint, basePos)
: {
x: startRelative.x / 2 + endRelative.x / 2,
y: startRelative.y / 2 + endRelative.y / 2,
}
const tailPos = constraintInCircle(
startRelative,
controlPoint,
startRadius!,
)
const headPos = constraintInCircle(
endRelative,
controlPoint,
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, controlPoint) * (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, controlPoint) * (180 / Math.PI)
}deg)`,
} as CSSProperties)
const svgStyle: CSSProperties = {
left: left + "px",
top: top + "px",
}
const segmentsRelatives = internalSegments.map(
({ next, controlPoint }) => {
return {
next: relativeTo(next, basePos),
cp: controlPoint
? relativeTo(controlPoint, basePos)
: undefined,
}
},
)
const computedSegments = segmentsRelatives
.map(({ next: n, cp }, idx) => {
let next = n
if (idx == internalSegments.length - 1) {
//if it is the last element
next = constraintInCircle(next, controlPoint, endRadius!)
}
if (cp == undefined) {
return `L${next.x - left} ${next.y - top}`
}
return `C${cp.x - left} ${cp.y - top}, ${cp.x - left} ${
cp.y - top
}, ${next.x - left} ${next.y - top}`
})
.join(" ")
const d = `M${tailPos.x - left} ${tailPos.y - top} ` + computedSegments
pathRef.current!.setAttribute("d", d)
Object.assign(svgRef.current!.style, svgStyle)
setControlPointsDots(computeControlPoints(basePos))
}, [startPos, internalSegments])
useEffect(update, [update])
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}
fill="none"
/>
</svg>
<div
className={"arrow-head"}
style={{ position: "absolute", transformOrigin: "center" }}
ref={headRef}>
{style?.head?.call(style) ?? <Triangle fill={"red"} />}
</div>
<div
className={"arrow-tail"}
style={{ position: "absolute", transformOrigin: "center" }}
ref={tailRef}>
{style?.tail?.call(style) ?? <Triangle fill={"blue"} />}
</div>
{isSelected && controlPointsDots}
</div>
)
}
interface ControlPointProps {
pos: Pos
basePos: Pos
onMoves: (currentPos: Pos) => void
onPosValidated: (newPos: Pos | undefined) => void
radius?: number
}
function ControlPoint({
pos,
onMoves,
onPosValidated,
radius = 7,
}: ControlPointProps) {
const ref = useRef<HTMLDivElement>(null)
return (
<Draggable
nodeRef={ref}
onStop={() => {
const pointPos = middlePos(ref.current!.getBoundingClientRect())
onPosValidated(pointPos)
}}
onDrag={() => {
const pointPos = middlePos(ref.current!.getBoundingClientRect())
onMoves(pointPos)
}}
position={{ x: pos.x - radius, y: pos.y - radius }}>
<div
ref={ref}
className={"arrow-edge-control-point"}
style={{
position: "absolute",
width: radius * 2,
height: radius * 2,
}}
onKeyDown={(e) => {
if (e.key == "Delete") {
onPosValidated(undefined)
}
}}
tabIndex={0}
/>
</Draggable>
)
}