diff --git a/front/components/actions/ArrowAction.tsx b/front/components/actions/ArrowAction.tsx index f1a2cc9..00a661c 100644 --- a/front/components/actions/ArrowAction.tsx +++ b/front/components/actions/ArrowAction.tsx @@ -59,16 +59,3 @@ export function MoveToHead() { ) } - -export function ShootHead() { - return ( - - - - ) -} diff --git a/front/components/arrows/BendableArrow.tsx b/front/components/arrows/BendableArrow.tsx index 7855a10..88680b6 100644 --- a/front/components/arrows/BendableArrow.tsx +++ b/front/components/arrows/BendableArrow.tsx @@ -4,6 +4,7 @@ import { RefObject, useCallback, useEffect, + useLayoutEffect, useRef, useState, } from "react" @@ -18,6 +19,8 @@ import { Pos, posWithinBase, ratioWithinBase, + relativeTo, + size, } from "./Pos" import "../../style/bendable_arrows.css" @@ -29,6 +32,7 @@ export interface BendableArrowProps { segments: Segment[] onSegmentsChanges: (edges: Segment[]) => void forceStraight: boolean + wavy: boolean startRadius?: number endRadius?: number @@ -45,8 +49,8 @@ export interface ArrowStyle { tail?: () => ReactElement } -const ArrowStyleDefaults = { - width: 4, +const ArrowStyleDefaults: ArrowStyle = { + width: 3, } export interface Segment { @@ -54,22 +58,51 @@ export interface Segment { controlPoint?: Pos } -function constraintInCircle(pos: Pos, from: Pos, radius: number): Pos { - const theta = angle(pos, from) +/** + * 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: pos.x - Math.sin(theta) * radius, - y: pos.y - Math.cos(theta) * radius, + 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, @@ -82,18 +115,46 @@ export default function BendableArrow({ const styleWidth = style?.width ?? ArrowStyleDefaults.width - useEffect(() => { - setInternalSegments(segments) - }, [segments]) - - const [internalSegments, setInternalSegments] = useState(segments) + // 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]) const [isSelected, setIsSelected] = useState(false) const headRef = useRef(null) const tailRef = useRef(null) - function computeControlPoints(parentBase: DOMRect) { + function computeInternalSegments(segments: Segment[]): FullSegment[] { + return segments.map((segment, idx) => { + if (idx == 0) { + return { + start: startPos, + controlPoint: segment.controlPoint, + end: segment.next, + } + } + const start = segments[idx - 1].next + return { + start, + controlPoint: segment.controlPoint, + end: segment.next, + } + }) + } + + /** + * 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 @@ -137,6 +198,7 @@ export default function BendableArrow({ //next pos point (only if this is not the last segment) i != segments.length - 1 && ( { + onMoves={(end) => { setInternalSegments((is) => { - return is.toSpliced(i, 1, { - ...is[i], - next, - }) + return is.toSpliced( + i, + 2, + { + ...is[i], + end, + }, + { + ...is[i + 1], + start: end, + }, + ) }) }} /> @@ -167,33 +237,30 @@ export default function BendableArrow({ }) } + /** + * Updates the states based on given parameters, which causes the arrow to re-render. + */ const update = useCallback(() => { const parentBase = area.current!.getBoundingClientRect() - const firstSegment = internalSegments[0] ?? null - if (firstSegment == null) - throw new Error("segments might not be empty.") + 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.next, parentBase) + const endRelative = posWithinBase(lastSegment.end, parentBase) const startNext = - firstSegment.controlPoint && !forceStraight - ? posWithinBase(firstSegment.controlPoint, parentBase) - : posWithinBase(firstSegment.next, parentBase) + segment.controlPoint && !forceStraight + ? posWithinBase(segment.controlPoint, parentBase) + : posWithinBase(segment.end, parentBase) const endPrevious = forceStraight ? startRelative : lastSegment.controlPoint ? posWithinBase(lastSegment.controlPoint, parentBase) - : internalSegments[internalSegments.length - 2] - ? posWithinBase( - internalSegments[internalSegments.length - 2].next, - parentBase, - ) - : startRelative + : posWithinBase(lastSegment.start, parentBase) const tailPos = constraintInCircle( startRelative, @@ -229,52 +296,95 @@ export default function BendableArrow({ } const segmentsRelatives = ( - forceStraight ? internalSegments.slice(-1) : internalSegments - ).map(({ next, controlPoint }, idx) => { - const nextPos = posWithinBase(next, parentBase) + 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, + ) + : between(startRelative, nextRelative) + return { - next: nextPos, - cp: - controlPoint && !forceStraight - ? posWithinBase(controlPoint, parentBase) - : between( - idx == 0 - ? startRelative - : posWithinBase( - internalSegments[idx - 1].next, - parentBase, - ), - nextPos, - ), + start: startRelative, + end: nextRelative, + cp: controlPointRelative, } }) const computedSegments = segmentsRelatives - .map(({ next: n, cp }, idx) => { - let next = n - - if (idx == internalSegments.length - 1) { + .map(({ start, cp, end: e }, idx) => { + let end = e + if (idx == segmentsRelatives.length - 1) { //if it is the last element - next = constraintInCircle(next, cp, endRadius!) + end = constraintInCircle(end, cp, endRadius!) + } + + const previousSegment = + idx != 0 ? segmentsRelatives[idx - 1] : undefined + + const previousSegmentCpAndCurrentPosVector = minus( + start, + previousSegment?.cp ?? between(start, end), + ) + + const smoothCp = previousSegment + ? add(start, previousSegmentCpAndCurrentPosVector) + : cp + + if (wavy) { + return wavyBezier(start, smoothCp, cp, end, 10, 10) } - return `C${cp.x - left} ${cp.y - top}, ${cp.x - left} ${ - cp.y - top - }, ${next.x - left} ${next.y - top}` + 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]) - + }, [ + 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) } @@ -288,6 +398,7 @@ export default function BendableArrow({ } }, [update, containerRef]) + // Inserts a segment where the mouse double clicks on the arrow useEffect(() => { if (forceStraight) return @@ -304,16 +415,30 @@ export default function BendableArrow({ 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 - let currentPos = i == 0 ? startPos : segments[i - 1].next - let nextPos = segment.next - let controlPointPos = segment.controlPoint + const currentPos = beforeSegment ? beforeSegment.next : startPos + const nextPos = segment.next + const segmentCp = segment.controlPoint ? segment.controlPoint : between(currentPos, nextPos) + const smoothCp = beforeSegment + ? add( + currentPos, + minus( + currentPos, + beforeSegment.controlPoint ?? + between(beforeSegmentPos, currentPos), + ), + ) + : segmentCp + const result = searchOnSegment( currentPos, - controlPointPos, + smoothCp, + segmentCp, nextPos, clickPosBaseRatio, 0.05, @@ -373,7 +498,9 @@ export default function BendableArrow({ ref={pathRef} stroke={"#000"} strokeWidth={styleWidth} - strokeDasharray={style?.dashArray} + strokeDasharray={ + style?.dashArray ?? ArrowStyleDefaults.dashArray + } fill="none" tabIndex={0} onKeyUp={(e) => { @@ -399,7 +526,7 @@ export default function BendableArrow({ {!forceStraight && isSelected && - computeControlPoints(area.current!.getBoundingClientRect())} + computePoints(area.current!.getBoundingClientRect())} ) } @@ -420,32 +547,154 @@ enum PointSegmentSearchResult { NOT_FOUND, } +interface FullSegment { + start: Pos + controlPoint: Pos | undefined + 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 = size(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( - startPos: Pos, - controlPoint: Pos, - endPos: Pos, + start: Pos, + cp1: Pos, + cp2: Pos, + end: 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) + const dist = distance(start, cp1) + distance(cp1, cp2) + distance(cp2, end) + const step = 1 / (dist / minDistance) 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) + return distance(cubicBeziers(start, cp1, cp2, end, t), point) } for (let t = 0; t < 1; t += step) { - if (getDistanceAt(t) <= minDistance) + const distance = getDistanceAt(t) + + if (distance <= minDistance) return t >= 0.5 ? PointSegmentSearchResult.RIGHT_TO_CONTROL_POINT : PointSegmentSearchResult.LEFT_TO_CONTROL_POINT @@ -454,14 +703,17 @@ function searchOnSegment( 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) -} - +/** + * 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, diff --git a/front/components/arrows/Pos.ts b/front/components/arrows/Pos.ts index 4ca08bd..5c651cc 100644 --- a/front/components/arrows/Pos.ts +++ b/front/components/arrows/Pos.ts @@ -38,6 +38,15 @@ export function distance(a: Pos, b: Pos): number { return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2) } +export function size(vector: Pos): number { + return distance(NULL_POS, vector) +} + +/** + * Returns the angle in radian between the two points + * @param a + * @param b + */ export function angle(a: Pos, b: Pos): number { const r = relativeTo(a, b) return Math.atan2(r.x, r.y) @@ -63,3 +72,10 @@ export function between(a: Pos, b: Pos): Pos { y: a.y / 2 + b.y / 2, } } + +export function rotate(vec: Pos, deg: number): Pos { + return { + x: Math.cos(deg * vec.x) - Math.sin(deg * vec.y), + y: Math.sin(deg * vec.x) + Math.cos(deg * vec.y), + } +} diff --git a/front/components/editor/BasketCourt.tsx b/front/components/editor/BasketCourt.tsx index cfa4bd2..e57d669 100644 --- a/front/components/editor/BasketCourt.tsx +++ b/front/components/editor/BasketCourt.tsx @@ -157,7 +157,7 @@ export function BasketCourt({ onDrag={() => updateActionsRelatedTo(player)} onChange={onPlayerChange} onRemove={() => onPlayerRemove(player)} - parentRef={courtRef} + courtRef={courtRef} availableActions={(pieceRef) => [ { player: Player onDrag: () => void onChange: (p: Player) => void onRemove: () => void - parentRef: RefObject + courtRef: RefObject availableActions: (ro: HTMLElement) => A[] } @@ -22,24 +22,24 @@ export default function CourtPlayer({ onDrag, onChange, onRemove, - parentRef, + courtRef, availableActions, }: PlayerProps) { + const hasBall = player.hasBall const x = player.rightRatio const y = player.bottomRatio - const hasBall = player.hasBall - const pieceRef = useRef(null) return ( { const pieceBounds = pieceRef.current!.getBoundingClientRect() - const parentBounds = parentRef.current!.getBoundingClientRect() + const parentBounds = courtRef.current!.getBoundingClientRect() const { x, y } = ratioWithinBase(pieceBounds, parentBounds) diff --git a/front/style/editor.css b/front/style/editor.css index a305323..258476a 100644 --- a/front/style/editor.css +++ b/front/style/editor.css @@ -93,6 +93,7 @@ } #court-image-div { + position: relative; background-color: white; height: 100%; width: 100%; diff --git a/front/views/editor/CourtAction.tsx b/front/views/editor/CourtAction.tsx index 44118e3..b4028ff 100644 --- a/front/views/editor/CourtAction.tsx +++ b/front/views/editor/CourtAction.tsx @@ -1,11 +1,7 @@ import { Action, ActionKind } from "../../tactic/Action" import BendableArrow from "../../components/arrows/BendableArrow" import { RefObject } from "react" -import { - MoveToHead, - ScreenHead, - ShootHead, -} from "../../components/actions/ArrowAction" +import { MoveToHead, ScreenHead } from "../../components/actions/ArrowAction" export interface CourtActionProps { action: Action @@ -24,13 +20,12 @@ export function CourtAction({ switch (action.type) { case ActionKind.DRIBBLE: case ActionKind.MOVE: + case ActionKind.SHOOT: head = () => break case ActionKind.SCREEN: head = () => break - case ActionKind.SHOOT: - head = () => } let dashArray @@ -38,8 +33,6 @@ export function CourtAction({ case ActionKind.SHOOT: dashArray = "10 5" break - case ActionKind.DRIBBLE: - dashArray = "4" } return ( @@ -51,6 +44,7 @@ export function CourtAction({ onSegmentsChanges={(edges) => { onActionChanges({ ...action, segments: edges }) }} + wavy={action.type == ActionKind.DRIBBLE} //TODO place those magic values in constants endRadius={action.toPlayerId ? 26 : 17} startRadius={0}