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}