add and remove multiple control points per arrows

pull/82/head
maxime.batista 1 year ago
parent df52d758ae
commit 727ab33644

@ -1,19 +1,16 @@
import {
CSSProperties,
ReactElement,
RefObject,
useCallback,
useEffect,
useRef,
useState,
} from "react"
import {CSSProperties, ReactElement, RefObject, useCallback, useEffect, useRef, useState,} from "react"
import {
add,
angle,
between,
distance,
middlePos,
minus,
mul,
Pos,
posWithinBase,
ratioWithinBase,
relativeTo,
} from "./Pos"
import "../../style/bendable_arrows.css"
@ -57,26 +54,27 @@ function constraintInCircle(pos: Pos, from: Pos, radius: number): Pos {
}
}
function Triangle({ fill }: { fill: string }) {
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} />
<polygon points={"50 0, 0 0, 25 40"} fill={fill}/>
</svg>
)
}
export default function BendableArrow({
area,
startPos,
segments,
onSegmentsChanges,
style,
startRadius = 0,
endRadius = 0,
onDeleteRequested = () => {},
}: BendableArrowProps) {
area,
startPos,
segments,
onSegmentsChanges,
style,
startRadius = 0,
endRadius = 0,
onDeleteRequested = () => {
},
}: BendableArrowProps) {
const containerRef = useRef<HTMLDivElement>(null)
const svgRef = useRef<SVGSVGElement>(null)
const pathRef = useRef<SVGPathElement>(null)
@ -95,7 +93,7 @@ export default function BendableArrow({
const tailRef = useRef<HTMLDivElement>(null)
function computeControlPoints(parentBase: DOMRect) {
return segments.map(({ next, controlPoint }, i) => {
return segments.flatMap(({next, controlPoint}, i) => {
const prev = i == 0 ? startPos : segments[i - 1].next
const prevRelative = posWithinBase(prev, parentBase)
@ -104,71 +102,99 @@ export default function BendableArrow({
const cpPos =
controlPoint ||
ratioWithinBase(
add(
{
x: prevRelative.x / 2 + nextRelative.x / 2,
y: prevRelative.y / 2 + nextRelative.y / 2,
},
parentBase,
),
add(between(prevRelative, nextRelative), parentBase),
parentBase,
)
return (
<ControlPoint
const setControlPointPos = (newPos: Pos | undefined) => {
const segment = segments[i]
const newSegments = segments.toSpliced(i, 1, {
...segment,
controlPoint: newPos,
})
onSegmentsChanges(newSegments)
}
return [
// curve control point
<ArrowPoint
key={i}
className={"arrow-point-control"}
posRatio={cpPos}
parentBase={parentBase}
onPosValidated={(controlPoint) => {
const segment = segments[i]
onPosValidated={setControlPointPos}
onRemove={() => setControlPointPos(undefined)}
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
className={"arrow-point-next"}
posRatio={next}
parentBase={parentBase}
onPosValidated={next => {
const currentSegment = segments[i]
const newSegments = segments.toSpliced(i, 1, {
...segment,
controlPoint,
...currentSegment,
next,
})
onSegmentsChanges(newSegments)
}}
onMoves={(controlPoint) => {
onRemove={() => {
onSegmentsChanges(segments.toSpliced(
Math.max(i - 1, 0),
1,
)
)
}}
onMoves={next => {
setInternalSegments((is) => {
return is.toSpliced(i, 1, {
...is[i],
controlPoint,
next,
})
})
}}
/>
)
]
})
}
const update = useCallback(() => {
const parentBase = area.current!.getBoundingClientRect()
// only one segment is supported for now, which is the first.
// any other segments will be ignored
const segment = internalSegments[0] ?? null
const firstSegment = internalSegments[0] ?? null
if (firstSegment == null) throw new Error("segments might not be empty.")
if (segment == null) throw new Error("segments might not be empty.")
const endPos = segment.next
const lastSegment = internalSegments[internalSegments.length - 1]
const startRelative = posWithinBase(startPos, parentBase)
const endRelative = posWithinBase(endPos, parentBase)
const endRelative = posWithinBase(lastSegment.next, parentBase)
const startNext = firstSegment.controlPoint
? posWithinBase(firstSegment.controlPoint, parentBase)
: posWithinBase(firstSegment.next, parentBase)
const controlPoint = segment.controlPoint
? posWithinBase(segment.controlPoint, parentBase)
: {
x: startRelative.x / 2 + endRelative.x / 2,
y: startRelative.y / 2 + endRelative.y / 2,
}
const endPrevious = lastSegment.controlPoint
? posWithinBase(lastSegment.controlPoint, parentBase)
: internalSegments[internalSegments.length - 2]
? posWithinBase(internalSegments[internalSegments.length - 2].next, parentBase)
: startRelative
const tailPos = constraintInCircle(
startRelative,
controlPoint,
startNext,
startRadius!,
)
const headPos = constraintInCircle(
endRelative,
controlPoint,
endPrevious,
endRadius!,
)
@ -180,7 +206,7 @@ export default function BendableArrow({
top: tailPos.y + "px",
transformOrigin: "top center",
transform: `translateX(-50%) rotate(${
-angle(tailPos, controlPoint) * (180 / Math.PI)
-angle(tailPos, startNext) * (180 / Math.PI)
}deg)`,
} as CSSProperties)
@ -189,7 +215,7 @@ export default function BendableArrow({
top: headPos.y + "px",
transformOrigin: "top center",
transform: `translateX(-50%) rotate(${
-angle(headPos, controlPoint) * (180 / Math.PI)
-angle(headPos, endPrevious) * (180 / Math.PI)
}deg)`,
} as CSSProperties)
@ -199,21 +225,24 @@ export default function BendableArrow({
}
const segmentsRelatives = internalSegments.map(
({ next, controlPoint }) => {
({next, controlPoint}, idx) => {
const nextPos = posWithinBase(next, parentBase)
return {
next: posWithinBase(next, parentBase),
next: nextPos,
cp: controlPoint
? posWithinBase(controlPoint, parentBase)
: {
x: startRelative.x / 2 + endRelative.x / 2,
y: startRelative.y / 2 + endRelative.y / 2,
},
: between(
idx == 0
? startRelative
: posWithinBase(internalSegments[idx - 1].next, parentBase),
nextPos
),
}
},
)
const computedSegments = segmentsRelatives
.map(({ next: n, cp }, idx) => {
.map(({next: n, cp}, idx) => {
let next = n
if (idx == internalSegments.length - 1) {
@ -251,10 +280,68 @@ export default function BendableArrow({
}
}, [update, containerRef])
useEffect(() => {
const addSegment = (e: MouseEvent) => {
const parentBase = area.current!.getBoundingClientRect()
const clickAbsolutePos: Pos = {x: e.x, y: e.y}
const clickPosBaseRatio = ratioWithinBase(clickAbsolutePos, parentBase)
let segmentInsertionIndex = -1
let segmentInsertionIsOnRightOfCP = false
for (let i = 0; i < segments.length; i++) {
const segment = segments[i]
let currentPos = i == 0 ? startPos : segments[i - 1].next
let nextPos = segment.next
let controlPointPos = segment.controlPoint ? segment.controlPoint : between(currentPos, nextPos)
const result = searchOnSegment(currentPos, controlPointPos, 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]
let newSegments: Segment[]
if (segmentInsertionIsOnRightOfCP) {
newSegments = segments.toSpliced(
segmentInsertionIndex,
1,
{next: clickPosBaseRatio, controlPoint: splicedSegment.controlPoint},
{next: splicedSegment.next, controlPoint: undefined}
)
} else {
newSegments = segments.toSpliced(
segmentInsertionIndex,
1,
{next: clickPosBaseRatio, controlPoint: undefined},
{next: splicedSegment.next, controlPoint: splicedSegment.controlPoint}
)
}
onSegmentsChanges(newSegments)
}
pathRef?.current?.addEventListener('dblclick', addSegment)
return () => {
pathRef?.current?.removeEventListener('dblclick', addSegment)
}
}, [pathRef, segments]);
return (
<div
ref={containerRef}
style={{ position: "absolute", top: 0, left: 0 }}>
style={{position: "absolute", top: 0, left: 0}}>
<svg
ref={svgRef}
style={{
@ -277,39 +364,93 @@ export default function BendableArrow({
<div
className={"arrow-head"}
style={{ position: "absolute", transformOrigin: "center" }}
style={{position: "absolute", transformOrigin: "center"}}
ref={headRef}>
{style?.head?.call(style) ?? <Triangle fill={"red"} />}
{style?.head?.call(style) ?? <Triangle fill={"red"}/>}
</div>
<div
className={"arrow-tail"}
style={{ position: "absolute", transformOrigin: "center" }}
style={{position: "absolute", transformOrigin: "center"}}
ref={tailRef}>
{style?.tail?.call(style) ?? <Triangle fill={"blue"} />}
{style?.tail?.call(style) ?? <Triangle fill={"blue"}/>}
</div>
{isSelected &&
computeControlPoints(area.current!.getBoundingClientRect())}
{isSelected && computeControlPoints(area.current!.getBoundingClientRect())}
</div>
)
}
interface ControlPointProps {
className: string
posRatio: Pos
parentBase: DOMRect
onMoves: (currentPos: Pos) => void
onPosValidated: (newPos: Pos | undefined) => void
onPosValidated: (newPos: Pos) => void
onRemove: () => void
radius?: number
}
function ControlPoint({
posRatio,
parentBase,
onMoves,
onPosValidated,
radius = 7,
}: ControlPointProps) {
enum PointSegmentSearchResult {
LEFT_TO_CONTROL_POINT,
RIGHT_TO_CONTROL_POINT,
NOT_FOUND
}
function searchOnSegment(startPos: Pos, controlPoint: Pos, endPos: Pos, point: Pos, minDistance: number): PointSegmentSearchResult {
const step = 1 / ((distance(startPos, controlPoint) + distance(controlPoint, endPos)) / minDistance)
const p0MinusP1 = minus(startPos, controlPoint)
const p2MinusP1 = minus(endPos, controlPoint)
function getDistanceAt(t: number): number {
// apply the bezier function
const pos = add(
add(
controlPoint,
mul(
p0MinusP1,
(1 - t) ** 2
)
),
mul(
p2MinusP1,
t ** 2
)
)
return distance(pos, point)
}
for (let t = 0; t < 1; t += step) {
if (getDistanceAt(t) <= minDistance)
return t >= 0.5
? PointSegmentSearchResult.RIGHT_TO_CONTROL_POINT
: PointSegmentSearchResult.LEFT_TO_CONTROL_POINT
}
return PointSegmentSearchResult.NOT_FOUND
}
let t = 0
let slice = 0.5
for (let i = 0; i < 100; i++) {
t += slice
slice /= 2
// console.log(t)
}
function ArrowPoint({
className,
posRatio,
parentBase,
onMoves,
onPosValidated,
onRemove,
radius = 7,
}: ControlPointProps) {
const ref = useRef<HTMLDivElement>(null)
const pos = posWithinBase(posRatio, parentBase)
@ -325,10 +466,10 @@ function ControlPoint({
const pointPos = middlePos(ref.current!.getBoundingClientRect())
onMoves(ratioWithinBase(pointPos, parentBase))
}}
position={{ x: pos.x - radius, y: pos.y - radius }}>
position={{x: pos.x - radius, y: pos.y - radius}}>
<div
ref={ref}
className={"arrow-edge-control-point"}
className={`arrow-point ${className}`}
style={{
position: "absolute",
width: radius * 2,
@ -336,7 +477,7 @@ function ControlPoint({
}}
onKeyUp={(e) => {
if (e.key == "Delete") {
onPosValidated(undefined)
onRemove()
}
}}
tabIndex={0}

@ -0,0 +1,35 @@
import {Pos} from "./Pos";
export interface Box {
x: number,
y: number,
width: number,
height: number
}
export function boundsOf(...positions: Pos[]): Box {
const allPosX = positions.map(p => p.x)
const allPosY = positions.map(p => p.y)
const x = Math.min(...allPosX)
const y = Math.min(...allPosY)
const width = Math.max(...allPosX) - x
const height = Math.max(...allPosY) - y
return {x, y, width, height}
}
export function surrounds(pos: Pos, width: number, height: number): Box {
return {
x: pos.x + (width / 2),
y: pos.y + (height / 2),
width,
height
}
}
export function contains(box: Box, pos: Pos): boolean {
return (pos.x >= box.x && pos.x <= box.x + box.width && pos.y >= box.y && pos.y <= box.y + box.height)
}

@ -3,7 +3,7 @@ export interface Pos {
y: number
}
export const NULL_POS: Pos = { x: 0, y: 0 }
export const NULL_POS: Pos = {x: 0, y: 0}
/**
* Returns position of a relative to b
@ -11,7 +11,7 @@ export const NULL_POS: Pos = { x: 0, y: 0 }
* @param b
*/
export function relativeTo(a: Pos, b: Pos): Pos {
return { x: a.x - b.x, y: a.y - b.y }
return {x: a.x - b.x, y: a.y - b.y}
}
/**
@ -19,20 +19,24 @@ export function relativeTo(a: Pos, b: Pos): Pos {
* @param rect
*/
export function middlePos(rect: DOMRect): Pos {
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }
return {x: rect.x + rect.width / 2, y: rect.y + rect.height / 2}
}
/**
* Returns x and y distance between given two pos
* @param a
* @param b
*/
export function size(a: Pos, b: Pos): Pos {
return { x: Math.abs(a.x - b.x), y: Math.abs(a.y - b.y) }
}
export function add(a: Pos, b: Pos): Pos {
return { x: a.x + b.x, y: a.y + b.y }
return {x: a.x + b.x, y: a.y + b.y}
}
export function minus(a: Pos, b: Pos): Pos {
return {x: a.x - b.x, y: a.y - b.y}
}
export function mul(a: Pos, t: number): Pos {
return {x: a.x * t, y: a.y * t}
}
export function distance(a: Pos, b: Pos): number {
return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2)
}
export function angle(a: Pos, b: Pos): number {
@ -53,3 +57,10 @@ export function posWithinBase(ratio: Pos, base: DOMRect): Pos {
y: ratio.y * base.height,
}
}
export function between(a: Pos, b: Pos): Pos {
return {
x: a.x / 2 + b.x / 2,
y: a.y / 2 + b.y / 2
}
}

@ -1,4 +1,4 @@
.arrow-edge-control-point {
.arrow-point {
cursor: pointer;
border-radius: 100px;
@ -6,7 +6,7 @@
outline: none;
}
.arrow-edge-control-point:hover {
.arrow-point:hover {
background-color: var(--selection-color);
}

@ -453,7 +453,6 @@ function EditorView({
onActionDeleted={() => {
setContent((content) => ({
...content,
players: content.players,
actions: content.actions.toSpliced(
i,
1,

Loading…
Cancel
Save