add conventional wavy arrow style for dribbles
continuous-integration/drone/push Build is passing Details

pull/82/head
maxime.batista 1 year ago
parent 26e99a3a03
commit 768eae92ad

@ -59,16 +59,3 @@ export function MoveToHead() {
</svg>
)
}
export function ShootHead() {
return (
<svg viewBox={"0 0 50 50"} width={15} height={15} overflow={"visible"}>
<path
d={"M0 0 L50 50 M0 50 L50 0"}
stroke="#000"
strokeWidth={10}
fill={"transparent"}
/>
</svg>
)
}

@ -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<FullSegment[]>(
() => 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<HTMLDivElement>(null)
const tailRef = useRef<HTMLDivElement>(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 && (
<ArrowPoint
key={i + "-2"}
className={"arrow-point-next"}
posRatio={next}
parentBase={parentBase}
@ -153,12 +215,20 @@ export default function BendableArrow({
segments.toSpliced(Math.max(i - 1, 0), 1),
)
}}
onMoves={(next) => {
onMoves={(end) => {
setInternalSegments((is) => {
return is.toSpliced(i, 1, {
return is.toSpliced(
i,
2,
{
...is[i],
next,
})
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)
return {
next: nextPos,
cp:
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
? posWithinBase(controlPoint, parentBase)
: between(
idx == 0
? startRelative
: posWithinBase(
internalSegments[idx - 1].next,
parentBase,
),
nextPos,
),
? relativeTo(
posWithinBase(controlPoint, parentBase),
svgPosRelativeToBase,
)
: between(startRelative, nextRelative)
return {
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)
}
if (forceStraight) {
return `L${end.x} ${end.y}`
}
return `C${cp.x - left} ${cp.y - top}, ${cp.x - left} ${
cp.y - top
}, ${next.x - left} ${next.y - top}`
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())}
</div>
)
}
@ -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,

@ -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),
}
}

@ -157,7 +157,7 @@ export function BasketCourt({
onDrag={() => updateActionsRelatedTo(player)}
onChange={onPlayerChange}
onRemove={() => onPlayerRemove(player)}
parentRef={courtRef}
courtRef={courtRef}
availableActions={(pieceRef) => [
<ArrowAction
key={1}

@ -3,14 +3,14 @@ import "../../style/player.css"
import Draggable from "react-draggable"
import { PlayerPiece } from "./PlayerPiece"
import { Player } from "../../tactic/Player"
import { ratioWithinBase } from "../arrows/Pos"
import { NULL_POS, ratioWithinBase } from "../arrows/Pos"
export interface PlayerProps<A extends ReactNode> {
player: Player
onDrag: () => void
onChange: (p: Player) => void
onRemove: () => void
parentRef: RefObject<HTMLElement>
courtRef: RefObject<HTMLElement>
availableActions: (ro: HTMLElement) => A[]
}
@ -22,24 +22,24 @@ export default function CourtPlayer<A extends ReactNode>({
onDrag,
onChange,
onRemove,
parentRef,
courtRef,
availableActions,
}: PlayerProps<A>) {
const hasBall = player.hasBall
const x = player.rightRatio
const y = player.bottomRatio
const hasBall = player.hasBall
const pieceRef = useRef<HTMLDivElement>(null)
return (
<Draggable
handle=".player-piece"
nodeRef={pieceRef}
position={{ x, y }}
onDrag={onDrag}
//The piece is positioned using top/bottom style attributes instead
position={NULL_POS}
onStop={() => {
const pieceBounds = pieceRef.current!.getBoundingClientRect()
const parentBounds = parentRef.current!.getBoundingClientRect()
const parentBounds = courtRef.current!.getBoundingClientRect()
const { x, y } = ratioWithinBase(pieceBounds, parentBounds)

@ -93,6 +93,7 @@
}
#court-image-div {
position: relative;
background-color: white;
height: 100%;
width: 100%;

@ -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 = () => <MoveToHead />
break
case ActionKind.SCREEN:
head = () => <ScreenHead />
break
case ActionKind.SHOOT:
head = () => <ShootHead />
}
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}

Loading…
Cancel
Save