From 727ab33644bcd55bfd9680cd9607afd195bd5cf3 Mon Sep 17 00:00:00 2001 From: "maxime.batista" Date: Fri, 22 Dec 2023 22:22:56 +0100 Subject: [PATCH] add and remove multiple control points per arrows --- front/components/arrows/BendableArrow.tsx | 303 ++++++++++++++++------ front/components/arrows/Box.ts | 35 +++ front/components/arrows/Pos.ts | 35 ++- front/style/bendable_arrows.css | 4 +- front/views/Editor.tsx | 1 - 5 files changed, 282 insertions(+), 96 deletions(-) create mode 100644 front/components/arrows/Box.ts diff --git a/front/components/arrows/BendableArrow.tsx b/front/components/arrows/BendableArrow.tsx index 4d42433..82c48c1 100644 --- a/front/components/arrows/BendableArrow.tsx +++ b/front/components/arrows/BendableArrow.tsx @@ -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 ( - + ) } 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(null) const svgRef = useRef(null) const pathRef = useRef(null) @@ -95,7 +93,7 @@ export default function BendableArrow({ const tailRef = useRef(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 ( - { + const segment = segments[i] + const newSegments = segments.toSpliced(i, 1, { + ...segment, + controlPoint: newPos, + }) + onSegmentsChanges(newSegments) + } + + return [ + // curve control point + { - 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 && { + 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 (
+ style={{position: "absolute", top: 0, left: 0}}> - {style?.head?.call(style) ?? } + {style?.head?.call(style) ?? }
- {style?.tail?.call(style) ?? } + {style?.tail?.call(style) ?? }
- {isSelected && - computeControlPoints(area.current!.getBoundingClientRect())} + {isSelected && computeControlPoints(area.current!.getBoundingClientRect())} ) } 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(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}}>
{ if (e.key == "Delete") { - onPosValidated(undefined) + onRemove() } }} tabIndex={0} diff --git a/front/components/arrows/Box.ts b/front/components/arrows/Box.ts new file mode 100644 index 0000000..6fd7548 --- /dev/null +++ b/front/components/arrows/Box.ts @@ -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) +} \ No newline at end of file diff --git a/front/components/arrows/Pos.ts b/front/components/arrows/Pos.ts index bb59627..08ddc7a 100644 --- a/front/components/arrows/Pos.ts +++ b/front/components/arrows/Pos.ts @@ -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 + } +} \ No newline at end of file diff --git a/front/style/bendable_arrows.css b/front/style/bendable_arrows.css index ad9fb83..65325f8 100644 --- a/front/style/bendable_arrows.css +++ b/front/style/bendable_arrows.css @@ -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); } diff --git a/front/views/Editor.tsx b/front/views/Editor.tsx index 610a3ab..7bd1a4c 100644 --- a/front/views/Editor.tsx +++ b/front/views/Editor.tsx @@ -453,7 +453,6 @@ function EditorView({ onActionDeleted={() => { setContent((content) => ({ ...content, - players: content.players, actions: content.actions.toSpliced( i, 1,