merged master into the branch team/completing
continuous-integration/drone/push Build is passing
Details
@ -1,12 +0,0 @@
|
|||||||
export function calculateRatio(
|
|
||||||
it: { x: number; y: number },
|
|
||||||
parent: DOMRect,
|
|
||||||
): { x: number; y: number } {
|
|
||||||
const relativeXPixels = it.x - parent.x
|
|
||||||
const relativeYPixels = it.y - parent.y
|
|
||||||
|
|
||||||
const xRatio = relativeXPixels / parent.width
|
|
||||||
const yRatio = relativeYPixels / parent.height
|
|
||||||
|
|
||||||
return { x: xRatio, y: yRatio }
|
|
||||||
}
|
|
Before Width: | Height: | Size: 3.2 KiB |
After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 6.0 KiB |
After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 427 B After Width: | Height: | Size: 405 B |
@ -0,0 +1,61 @@
|
|||||||
|
import "../../style/actions/arrow_action.css"
|
||||||
|
import Draggable from "react-draggable"
|
||||||
|
import arrowPng from "../../assets/icon/arrow.svg"
|
||||||
|
import { useRef } from "react"
|
||||||
|
|
||||||
|
export interface ArrowActionProps {
|
||||||
|
onHeadDropped: (headBounds: DOMRect) => void
|
||||||
|
onHeadPicked: (headBounds: DOMRect) => void
|
||||||
|
onHeadMoved: (headBounds: DOMRect) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ArrowAction({
|
||||||
|
onHeadDropped,
|
||||||
|
onHeadPicked,
|
||||||
|
onHeadMoved,
|
||||||
|
}: ArrowActionProps) {
|
||||||
|
const arrowHeadRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="arrow-action">
|
||||||
|
<img className="arrow-action-icon" src={arrowPng} alt="add arrow" />
|
||||||
|
|
||||||
|
<Draggable
|
||||||
|
nodeRef={arrowHeadRef}
|
||||||
|
onStart={() => {
|
||||||
|
const headBounds =
|
||||||
|
arrowHeadRef.current!.getBoundingClientRect()
|
||||||
|
onHeadPicked(headBounds)
|
||||||
|
}}
|
||||||
|
onStop={() => {
|
||||||
|
const headBounds =
|
||||||
|
arrowHeadRef.current!.getBoundingClientRect()
|
||||||
|
onHeadDropped(headBounds)
|
||||||
|
}}
|
||||||
|
onDrag={() => {
|
||||||
|
const headBounds =
|
||||||
|
arrowHeadRef.current!.getBoundingClientRect()
|
||||||
|
onHeadMoved(headBounds)
|
||||||
|
}}
|
||||||
|
position={{ x: 0, y: 0 }}>
|
||||||
|
<div ref={arrowHeadRef} className="arrow-head-pick" />
|
||||||
|
</Draggable>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScreenHead() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{ backgroundColor: "black", height: "5px", width: "25px" }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MoveToHead() {
|
||||||
|
return (
|
||||||
|
<svg viewBox={"0 0 50 50"} width={20} height={20}>
|
||||||
|
<polygon points={"50 0, 0 0, 25 40"} fill="#000" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,18 @@
|
|||||||
|
import { BallPiece } from "../editor/BallPiece"
|
||||||
|
import Draggable from "react-draggable"
|
||||||
|
import { useRef } from "react"
|
||||||
|
|
||||||
|
export interface BallActionProps {
|
||||||
|
onDrop: (el: HTMLElement) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BallAction({ onDrop }: BallActionProps) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
return (
|
||||||
|
<Draggable onStop={() => onDrop(ref.current!)} nodeRef={ref}>
|
||||||
|
<div ref={ref}>
|
||||||
|
<BallPiece />
|
||||||
|
</div>
|
||||||
|
</Draggable>
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,757 @@
|
|||||||
|
import {
|
||||||
|
CSSProperties,
|
||||||
|
ReactElement,
|
||||||
|
RefObject,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
MouseEvent as ReactMouseEvent,
|
||||||
|
} from "react"
|
||||||
|
import {
|
||||||
|
add,
|
||||||
|
angle,
|
||||||
|
middle,
|
||||||
|
distance,
|
||||||
|
middlePos,
|
||||||
|
minus,
|
||||||
|
mul,
|
||||||
|
Pos,
|
||||||
|
posWithinBase,
|
||||||
|
ratioWithinBase,
|
||||||
|
relativeTo,
|
||||||
|
norm,
|
||||||
|
} from "./Pos"
|
||||||
|
|
||||||
|
import "../../style/bendable_arrows.css"
|
||||||
|
import Draggable from "react-draggable"
|
||||||
|
|
||||||
|
export interface BendableArrowProps {
|
||||||
|
area: RefObject<HTMLElement>
|
||||||
|
startPos: Pos
|
||||||
|
segments: Segment[]
|
||||||
|
onSegmentsChanges: (edges: Segment[]) => void
|
||||||
|
forceStraight: boolean
|
||||||
|
wavy: boolean
|
||||||
|
|
||||||
|
startRadius?: number
|
||||||
|
endRadius?: number
|
||||||
|
|
||||||
|
onDeleteRequested?: () => void
|
||||||
|
|
||||||
|
style?: ArrowStyle
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArrowStyle {
|
||||||
|
width?: number
|
||||||
|
dashArray?: string
|
||||||
|
head?: () => ReactElement
|
||||||
|
tail?: () => ReactElement
|
||||||
|
}
|
||||||
|
|
||||||
|
const ArrowStyleDefaults: ArrowStyle = {
|
||||||
|
width: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Segment {
|
||||||
|
next: Pos
|
||||||
|
controlPoint?: Pos
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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: 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,
|
||||||
|
endRadius = 0,
|
||||||
|
onDeleteRequested,
|
||||||
|
}: BendableArrowProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const svgRef = useRef<SVGSVGElement>(null)
|
||||||
|
const pathRef = useRef<SVGPathElement>(null)
|
||||||
|
|
||||||
|
const styleWidth = style?.width ?? ArrowStyleDefaults.width
|
||||||
|
|
||||||
|
const computeInternalSegments = useCallback(
|
||||||
|
(segments: Segment[]) => {
|
||||||
|
return segments.map((segment, idx) => {
|
||||||
|
if (idx == 0) {
|
||||||
|
return {
|
||||||
|
start: startPos,
|
||||||
|
controlPoint: segment.controlPoint ?? null,
|
||||||
|
end: segment.next,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const start = segments[idx - 1].next
|
||||||
|
return {
|
||||||
|
start,
|
||||||
|
controlPoint: segment.controlPoint ?? null,
|
||||||
|
end: segment.next,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[segments, startPos],
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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, computeInternalSegments])
|
||||||
|
|
||||||
|
const [isSelected, setIsSelected] = useState(false)
|
||||||
|
|
||||||
|
const headRef = useRef<HTMLDivElement>(null)
|
||||||
|
const tailRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
|
||||||
|
const prevRelative = posWithinBase(prev, parentBase)
|
||||||
|
const nextRelative = posWithinBase(next, parentBase)
|
||||||
|
|
||||||
|
const cpPos =
|
||||||
|
controlPoint ||
|
||||||
|
ratioWithinBase(
|
||||||
|
add(middle(prevRelative, nextRelative), parentBase),
|
||||||
|
parentBase,
|
||||||
|
)
|
||||||
|
|
||||||
|
const setControlPointPos = (newPos: Pos | null) => {
|
||||||
|
const segment = segments[i]
|
||||||
|
const newSegments = segments.toSpliced(i, 1, {
|
||||||
|
...segment,
|
||||||
|
controlPoint: newPos ?? undefined,
|
||||||
|
})
|
||||||
|
onSegmentsChanges(newSegments)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
// curve control point
|
||||||
|
<ArrowPoint
|
||||||
|
key={i}
|
||||||
|
className={"arrow-point-control"}
|
||||||
|
posRatio={cpPos}
|
||||||
|
parentBase={parentBase}
|
||||||
|
onPosValidated={setControlPointPos}
|
||||||
|
onRemove={() => setControlPointPos(null)}
|
||||||
|
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
|
||||||
|
key={i + "-2"}
|
||||||
|
className={"arrow-point-next"}
|
||||||
|
posRatio={next}
|
||||||
|
parentBase={parentBase}
|
||||||
|
onPosValidated={(next) => {
|
||||||
|
const currentSegment = segments[i]
|
||||||
|
const newSegments = segments.toSpliced(i, 1, {
|
||||||
|
...currentSegment,
|
||||||
|
next,
|
||||||
|
})
|
||||||
|
onSegmentsChanges(newSegments)
|
||||||
|
}}
|
||||||
|
onRemove={() => {
|
||||||
|
onSegmentsChanges(
|
||||||
|
segments.toSpliced(Math.max(i - 1, 0), 1),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
onMoves={(end) => {
|
||||||
|
setInternalSegments((is) => {
|
||||||
|
return is.toSpliced(
|
||||||
|
i,
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
...is[i],
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...is[i + 1],
|
||||||
|
start: end,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the states based on given parameters, which causes the arrow to re-render.
|
||||||
|
*/
|
||||||
|
const update = useCallback(() => {
|
||||||
|
const parentBase = area.current!.getBoundingClientRect()
|
||||||
|
|
||||||
|
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.end, parentBase)
|
||||||
|
|
||||||
|
const startNext =
|
||||||
|
segment.controlPoint && !forceStraight
|
||||||
|
? posWithinBase(segment.controlPoint, parentBase)
|
||||||
|
: posWithinBase(segment.end, parentBase)
|
||||||
|
|
||||||
|
const endPrevious = forceStraight
|
||||||
|
? startRelative
|
||||||
|
: lastSegment.controlPoint
|
||||||
|
? posWithinBase(lastSegment.controlPoint, parentBase)
|
||||||
|
: posWithinBase(lastSegment.start, parentBase)
|
||||||
|
|
||||||
|
const tailPos = constraintInCircle(
|
||||||
|
startRelative,
|
||||||
|
startNext,
|
||||||
|
startRadius!,
|
||||||
|
)
|
||||||
|
const headPos = constraintInCircle(endRelative, endPrevious, endRadius!)
|
||||||
|
|
||||||
|
const left = Math.min(tailPos.x, headPos.x)
|
||||||
|
const top = Math.min(tailPos.y, headPos.y)
|
||||||
|
|
||||||
|
Object.assign(tailRef.current!.style, {
|
||||||
|
left: tailPos.x + "px",
|
||||||
|
top: tailPos.y + "px",
|
||||||
|
transformOrigin: "top center",
|
||||||
|
transform: `translateX(-50%) rotate(${
|
||||||
|
-angle(tailPos, startNext) * (180 / Math.PI)
|
||||||
|
}deg)`,
|
||||||
|
} as CSSProperties)
|
||||||
|
|
||||||
|
Object.assign(headRef.current!.style, {
|
||||||
|
left: headPos.x + "px",
|
||||||
|
top: headPos.y + "px",
|
||||||
|
transformOrigin: "top center",
|
||||||
|
transform: `translateX(-50%) rotate(${
|
||||||
|
-angle(headPos, endPrevious) * (180 / Math.PI)
|
||||||
|
}deg)`,
|
||||||
|
} as CSSProperties)
|
||||||
|
|
||||||
|
const svgStyle: CSSProperties = {
|
||||||
|
left: left + "px",
|
||||||
|
top: top + "px",
|
||||||
|
}
|
||||||
|
|
||||||
|
const segmentsRelatives = (
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
: middle(startRelative, nextRelative)
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: startRelative,
|
||||||
|
end: nextRelative,
|
||||||
|
cp: controlPointRelative,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const computedSegments = segmentsRelatives
|
||||||
|
.map(({ start, cp, end: e }, idx) => {
|
||||||
|
let end = e
|
||||||
|
if (idx == segmentsRelatives.length - 1) {
|
||||||
|
//if it is the last element
|
||||||
|
end = constraintInCircle(end, cp, endRadius!)
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousSegment =
|
||||||
|
idx != 0 ? segmentsRelatives[idx - 1] : undefined
|
||||||
|
|
||||||
|
const previousSegmentCpAndCurrentPosVector = minus(
|
||||||
|
start,
|
||||||
|
previousSegment?.cp ?? middle(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${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,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", selectionHandler)
|
||||||
|
window.addEventListener("resize", update)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", selectionHandler)
|
||||||
|
window.removeEventListener("resize", update)
|
||||||
|
}
|
||||||
|
}, [update, containerRef])
|
||||||
|
|
||||||
|
const addSegment = useCallback(
|
||||||
|
(e: ReactMouseEvent) => {
|
||||||
|
if (forceStraight) return
|
||||||
|
const parentBase = area.current!.getBoundingClientRect()
|
||||||
|
|
||||||
|
const clickAbsolutePos: Pos = { x: e.pageX, y: e.pageY }
|
||||||
|
const clickPosBaseRatio = ratioWithinBase(
|
||||||
|
clickAbsolutePos,
|
||||||
|
parentBase,
|
||||||
|
)
|
||||||
|
|
||||||
|
let segmentInsertionIndex = -1
|
||||||
|
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
|
||||||
|
|
||||||
|
const currentPos = beforeSegment ? beforeSegment.next : startPos
|
||||||
|
const nextPos = segment.next
|
||||||
|
const segmentCp = segment.controlPoint
|
||||||
|
? segment.controlPoint
|
||||||
|
: middle(currentPos, nextPos)
|
||||||
|
|
||||||
|
const smoothCp = beforeSegment
|
||||||
|
? add(
|
||||||
|
currentPos,
|
||||||
|
minus(
|
||||||
|
currentPos,
|
||||||
|
beforeSegment.controlPoint ??
|
||||||
|
middle(beforeSegmentPos, currentPos),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: segmentCp
|
||||||
|
|
||||||
|
const result = searchOnSegment(
|
||||||
|
currentPos,
|
||||||
|
smoothCp,
|
||||||
|
segmentCp,
|
||||||
|
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]
|
||||||
|
|
||||||
|
onSegmentsChanges(
|
||||||
|
segments.toSpliced(
|
||||||
|
segmentInsertionIndex,
|
||||||
|
1,
|
||||||
|
{
|
||||||
|
next: clickPosBaseRatio,
|
||||||
|
controlPoint: segmentInsertionIsOnRightOfCP
|
||||||
|
? splicedSegment.controlPoint
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
next: splicedSegment.next,
|
||||||
|
controlPoint: segmentInsertionIsOnRightOfCP
|
||||||
|
? undefined
|
||||||
|
: splicedSegment.controlPoint,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[area, forceStraight, onSegmentsChanges, segments, startPos],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
style={{ position: "absolute", top: 0, left: 0 }}>
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
style={{
|
||||||
|
overflow: "visible",
|
||||||
|
position: "absolute",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}>
|
||||||
|
<path
|
||||||
|
className="arrow-path"
|
||||||
|
ref={pathRef}
|
||||||
|
stroke={"#000"}
|
||||||
|
strokeWidth={styleWidth}
|
||||||
|
strokeDasharray={
|
||||||
|
style?.dashArray ?? ArrowStyleDefaults.dashArray
|
||||||
|
}
|
||||||
|
fill="none"
|
||||||
|
tabIndex={0}
|
||||||
|
onDoubleClick={addSegment}
|
||||||
|
onKeyUp={(e) => {
|
||||||
|
if (onDeleteRequested && e.key == "Delete")
|
||||||
|
onDeleteRequested()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={"arrow-head"}
|
||||||
|
style={{ position: "absolute", transformOrigin: "center" }}
|
||||||
|
ref={headRef}>
|
||||||
|
{style?.head?.call(style)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={"arrow-tail"}
|
||||||
|
style={{ position: "absolute", transformOrigin: "center" }}
|
||||||
|
ref={tailRef}>
|
||||||
|
{style?.tail?.call(style)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!forceStraight &&
|
||||||
|
isSelected &&
|
||||||
|
computePoints(area.current!.getBoundingClientRect())}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ControlPointProps {
|
||||||
|
className: string
|
||||||
|
posRatio: Pos
|
||||||
|
parentBase: DOMRect
|
||||||
|
onMoves: (currentPos: Pos) => void
|
||||||
|
onPosValidated: (newPos: Pos) => void
|
||||||
|
onRemove: () => void
|
||||||
|
radius?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PointSegmentSearchResult {
|
||||||
|
LEFT_TO_CONTROL_POINT,
|
||||||
|
RIGHT_TO_CONTROL_POINT,
|
||||||
|
NOT_FOUND,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FullSegment {
|
||||||
|
start: Pos
|
||||||
|
controlPoint: Pos | null
|
||||||
|
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 = norm(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(
|
||||||
|
start: Pos,
|
||||||
|
cp1: Pos,
|
||||||
|
cp2: Pos,
|
||||||
|
end: Pos,
|
||||||
|
point: Pos,
|
||||||
|
minDistance: number,
|
||||||
|
): PointSegmentSearchResult {
|
||||||
|
const dist = distance(start, cp1) + distance(cp1, cp2) + distance(cp2, end)
|
||||||
|
const step = 1 / (dist / minDistance)
|
||||||
|
|
||||||
|
function getDistanceAt(t: number): number {
|
||||||
|
return distance(cubicBeziers(start, cp1, cp2, end, t), point)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let t = 0; t < 1; t += step) {
|
||||||
|
const distance = getDistanceAt(t)
|
||||||
|
|
||||||
|
if (distance <= minDistance)
|
||||||
|
return t >= 0.5
|
||||||
|
? PointSegmentSearchResult.RIGHT_TO_CONTROL_POINT
|
||||||
|
: PointSegmentSearchResult.LEFT_TO_CONTROL_POINT
|
||||||
|
}
|
||||||
|
|
||||||
|
return PointSegmentSearchResult.NOT_FOUND
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
parentBase,
|
||||||
|
onMoves,
|
||||||
|
onPosValidated,
|
||||||
|
onRemove,
|
||||||
|
radius = 7,
|
||||||
|
}: ControlPointProps) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const pos = posWithinBase(posRatio, parentBase)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Draggable
|
||||||
|
nodeRef={ref}
|
||||||
|
onStop={() => {
|
||||||
|
const pointPos = middlePos(ref.current!.getBoundingClientRect())
|
||||||
|
onPosValidated(ratioWithinBase(pointPos, parentBase))
|
||||||
|
}}
|
||||||
|
onDrag={() => {
|
||||||
|
const pointPos = middlePos(ref.current!.getBoundingClientRect())
|
||||||
|
onMoves(ratioWithinBase(pointPos, parentBase))
|
||||||
|
}}
|
||||||
|
position={{ x: pos.x - radius, y: pos.y - radius }}>
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={`arrow-point ${className}`}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: radius * 2,
|
||||||
|
height: radius * 2,
|
||||||
|
}}
|
||||||
|
onKeyUp={(e) => {
|
||||||
|
if (e.key == "Delete") {
|
||||||
|
onRemove()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
/>
|
||||||
|
</Draggable>
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,38 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,81 @@
|
|||||||
|
export interface Pos {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NULL_POS: Pos = { x: 0, y: 0 }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns position of a relative to b
|
||||||
|
* @param a
|
||||||
|
* @param b
|
||||||
|
*/
|
||||||
|
export function relativeTo(a: Pos, b: Pos): Pos {
|
||||||
|
return { x: a.x - b.x, y: a.y - b.y }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the middle position of the given rectangle
|
||||||
|
* @param rect
|
||||||
|
*/
|
||||||
|
export function middlePos(rect: DOMRect): Pos {
|
||||||
|
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function add(a: Pos, b: Pos): Pos {
|
||||||
|
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 norm(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ratioWithinBase(pos: Pos, base: DOMRect): Pos {
|
||||||
|
return {
|
||||||
|
x: (pos.x - base.x) / base.width,
|
||||||
|
y: (pos.y - base.y) / base.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function posWithinBase(ratio: Pos, base: DOMRect): Pos {
|
||||||
|
return {
|
||||||
|
x: ratio.x * base.width,
|
||||||
|
y: ratio.y * base.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function middle(a: Pos, b: Pos): Pos {
|
||||||
|
return {
|
||||||
|
x: a.x / 2 + b.x / 2,
|
||||||
|
y: a.y / 2 + b.y / 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rotate(vec: Pos, rad: number): Pos {
|
||||||
|
return {
|
||||||
|
x: Math.cos(rad) * vec.x - Math.sin(rad) * vec.y,
|
||||||
|
y: Math.sin(rad) * vec.x + Math.cos(rad) * vec.y,
|
||||||
|
}
|
||||||
|
}
|
@ -1,21 +1,7 @@
|
|||||||
import React, { RefObject } from "react"
|
|
||||||
|
|
||||||
import "../../style/ball.css"
|
import "../../style/ball.css"
|
||||||
|
|
||||||
import Ball from "../../assets/icon/ball.svg?react"
|
import BallSvg from "../../assets/icon/ball.svg?react"
|
||||||
import Draggable from "react-draggable"
|
|
||||||
|
|
||||||
export interface BallPieceProps {
|
|
||||||
onDrop: () => void
|
|
||||||
pieceRef: RefObject<HTMLDivElement>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BallPiece({ onDrop, pieceRef }: BallPieceProps) {
|
export function BallPiece() {
|
||||||
return (
|
return <BallSvg className={"ball"} />
|
||||||
<Draggable onStop={onDrop} nodeRef={pieceRef} position={{ x: 0, y: 0 }}>
|
|
||||||
<div className={`ball-div`} ref={pieceRef}>
|
|
||||||
<Ball className={"ball"} />
|
|
||||||
</div>
|
|
||||||
</Draggable>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
@ -1,44 +1,272 @@
|
|||||||
import CourtSvg from "../../assets/basketball_court.svg?react"
|
import { CourtBall } from "./CourtBall"
|
||||||
import "../../style/basket_court.css"
|
|
||||||
import { RefObject, useRef } from "react"
|
import {
|
||||||
|
ReactElement,
|
||||||
|
RefObject,
|
||||||
|
useCallback,
|
||||||
|
useLayoutEffect,
|
||||||
|
useState,
|
||||||
|
} from "react"
|
||||||
import CourtPlayer from "./CourtPlayer"
|
import CourtPlayer from "./CourtPlayer"
|
||||||
|
|
||||||
import { Player } from "../../model/tactic/Player"
|
import { Player } from "../../model/tactic/Player"
|
||||||
|
import { Action, ActionKind } from "../../model/tactic/Action"
|
||||||
|
import ArrowAction from "../actions/ArrowAction"
|
||||||
|
import { middlePos, ratioWithinBase } from "../arrows/Pos"
|
||||||
|
import BallAction from "../actions/BallAction"
|
||||||
|
import { CourtObject } from "../../model/tactic/CourtObjects"
|
||||||
|
import { contains } from "../arrows/Box"
|
||||||
|
import { CourtAction } from "../../views/editor/CourtAction"
|
||||||
|
|
||||||
export interface BasketCourtProps {
|
export interface BasketCourtProps {
|
||||||
players: Player[]
|
players: Player[]
|
||||||
|
actions: Action[]
|
||||||
|
objects: CourtObject[]
|
||||||
|
|
||||||
|
renderAction: (a: Action, key: number) => ReactElement
|
||||||
|
setActions: (f: (a: Action[]) => Action[]) => void
|
||||||
|
|
||||||
onPlayerRemove: (p: Player) => void
|
onPlayerRemove: (p: Player) => void
|
||||||
onBallDrop: (ref: HTMLDivElement) => void
|
|
||||||
onPlayerChange: (p: Player) => void
|
onPlayerChange: (p: Player) => void
|
||||||
courtImage: string
|
|
||||||
|
onBallRemove: () => void
|
||||||
|
onBallMoved: (ball: DOMRect) => void
|
||||||
|
|
||||||
|
courtImage: ReactElement
|
||||||
courtRef: RefObject<HTMLDivElement>
|
courtRef: RefObject<HTMLDivElement>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BasketCourt({
|
export function BasketCourt({
|
||||||
players,
|
players,
|
||||||
|
actions,
|
||||||
|
objects,
|
||||||
|
renderAction,
|
||||||
|
setActions,
|
||||||
onPlayerRemove,
|
onPlayerRemove,
|
||||||
onBallDrop,
|
|
||||||
onPlayerChange,
|
onPlayerChange,
|
||||||
|
|
||||||
|
onBallMoved,
|
||||||
|
onBallRemove,
|
||||||
|
|
||||||
courtImage,
|
courtImage,
|
||||||
courtRef,
|
courtRef,
|
||||||
}: BasketCourtProps) {
|
}: BasketCourtProps) {
|
||||||
|
function placeArrow(origin: Player, arrowHead: DOMRect) {
|
||||||
|
const originRef = document.getElementById(origin.id)!
|
||||||
|
const courtBounds = courtRef.current!.getBoundingClientRect()
|
||||||
|
const start = ratioWithinBase(
|
||||||
|
middlePos(originRef.getBoundingClientRect()),
|
||||||
|
courtBounds,
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const player of players) {
|
||||||
|
if (player.id == origin.id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerBounds = document
|
||||||
|
.getElementById(player.id)!
|
||||||
|
.getBoundingClientRect()
|
||||||
|
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
playerBounds.top > arrowHead.bottom ||
|
||||||
|
playerBounds.right < arrowHead.left ||
|
||||||
|
playerBounds.bottom < arrowHead.top ||
|
||||||
|
playerBounds.left > arrowHead.right
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const targetPos = document
|
||||||
|
.getElementById(player.id)!
|
||||||
|
.getBoundingClientRect()
|
||||||
|
|
||||||
|
const end = ratioWithinBase(middlePos(targetPos), courtBounds)
|
||||||
|
|
||||||
|
const action: Action = {
|
||||||
|
fromPlayerId: originRef.id,
|
||||||
|
toPlayerId: player.id,
|
||||||
|
type: origin.hasBall ? ActionKind.SHOOT : ActionKind.SCREEN,
|
||||||
|
moveFrom: start,
|
||||||
|
segments: [{ next: end }],
|
||||||
|
}
|
||||||
|
setActions((actions) => [...actions, action])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const action: Action = {
|
||||||
|
fromPlayerId: originRef.id,
|
||||||
|
type: origin.hasBall ? ActionKind.DRIBBLE : ActionKind.MOVE,
|
||||||
|
moveFrom: ratioWithinBase(
|
||||||
|
middlePos(originRef.getBoundingClientRect()),
|
||||||
|
courtBounds,
|
||||||
|
),
|
||||||
|
segments: [
|
||||||
|
{ next: ratioWithinBase(middlePos(arrowHead), courtBounds) },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
setActions((actions) => [...actions, action])
|
||||||
|
}
|
||||||
|
|
||||||
|
const [previewAction, setPreviewAction] = useState<Action | null>(null)
|
||||||
|
|
||||||
|
const updateActionsRelatedTo = useCallback((player: Player) => {
|
||||||
|
const newPos = ratioWithinBase(
|
||||||
|
middlePos(
|
||||||
|
document.getElementById(player.id)!.getBoundingClientRect(),
|
||||||
|
),
|
||||||
|
courtRef.current!.getBoundingClientRect(),
|
||||||
|
)
|
||||||
|
setActions((actions) =>
|
||||||
|
actions.map((a) => {
|
||||||
|
if (a.fromPlayerId == player.id) {
|
||||||
|
return { ...a, moveFrom: newPos }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a.toPlayerId == player.id) {
|
||||||
|
const segments = a.segments.toSpliced(
|
||||||
|
a.segments.length - 1,
|
||||||
|
1,
|
||||||
|
{
|
||||||
|
...a.segments[a.segments.length - 1],
|
||||||
|
next: newPos,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return { ...a, segments }
|
||||||
|
}
|
||||||
|
|
||||||
|
return a
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const [internActions, setInternActions] = useState<Action[]>([])
|
||||||
|
|
||||||
|
useLayoutEffect(() => setInternActions(actions), [actions])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id="court-container"
|
className="court-container"
|
||||||
ref={courtRef}
|
ref={courtRef}
|
||||||
style={{ position: "relative" }}>
|
style={{ position: "relative" }}>
|
||||||
<img src={courtImage} alt={"court"} id="court-svg" />
|
{courtImage}
|
||||||
{players.map((player) => {
|
|
||||||
return (
|
{players.map((player) => (
|
||||||
<CourtPlayer
|
<CourtPlayer
|
||||||
key={player.team + player.role}
|
key={player.id}
|
||||||
player={player}
|
player={player}
|
||||||
onChange={onPlayerChange}
|
onDrag={() => updateActionsRelatedTo(player)}
|
||||||
onRemove={() => onPlayerRemove(player)}
|
onChange={onPlayerChange}
|
||||||
onBallDrop={onBallDrop}
|
onRemove={() => onPlayerRemove(player)}
|
||||||
parentRef={courtRef}
|
courtRef={courtRef}
|
||||||
/>
|
availableActions={(pieceRef) => [
|
||||||
)
|
<ArrowAction
|
||||||
|
key={1}
|
||||||
|
onHeadMoved={(headPos) => {
|
||||||
|
const baseBounds =
|
||||||
|
courtRef.current!.getBoundingClientRect()
|
||||||
|
|
||||||
|
const arrowHeadPos = middlePos(headPos)
|
||||||
|
|
||||||
|
const target = players.find(
|
||||||
|
(p) =>
|
||||||
|
p != player &&
|
||||||
|
contains(
|
||||||
|
document
|
||||||
|
.getElementById(p.id)!
|
||||||
|
.getBoundingClientRect(),
|
||||||
|
arrowHeadPos,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
setPreviewAction((action) => ({
|
||||||
|
...action!,
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
next: ratioWithinBase(
|
||||||
|
arrowHeadPos,
|
||||||
|
baseBounds,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
type: player.hasBall
|
||||||
|
? target
|
||||||
|
? ActionKind.SHOOT
|
||||||
|
: ActionKind.DRIBBLE
|
||||||
|
: target
|
||||||
|
? ActionKind.SCREEN
|
||||||
|
: ActionKind.MOVE,
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
onHeadPicked={(headPos) => {
|
||||||
|
;(document.activeElement as HTMLElement).blur()
|
||||||
|
const baseBounds =
|
||||||
|
courtRef.current!.getBoundingClientRect()
|
||||||
|
|
||||||
|
setPreviewAction({
|
||||||
|
type: player.hasBall
|
||||||
|
? ActionKind.DRIBBLE
|
||||||
|
: ActionKind.MOVE,
|
||||||
|
fromPlayerId: player.id,
|
||||||
|
toPlayerId: undefined,
|
||||||
|
moveFrom: ratioWithinBase(
|
||||||
|
middlePos(
|
||||||
|
pieceRef.getBoundingClientRect(),
|
||||||
|
),
|
||||||
|
baseBounds,
|
||||||
|
),
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
next: ratioWithinBase(
|
||||||
|
middlePos(headPos),
|
||||||
|
baseBounds,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onHeadDropped={(headRect) => {
|
||||||
|
placeArrow(player, headRect)
|
||||||
|
setPreviewAction(null)
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
player.hasBall && (
|
||||||
|
<BallAction
|
||||||
|
key={2}
|
||||||
|
onDrop={(ref) =>
|
||||||
|
onBallMoved(ref.getBoundingClientRect())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{internActions.map((action, idx) => renderAction(action, idx))}
|
||||||
|
|
||||||
|
{objects.map((object) => {
|
||||||
|
if (object.type == "ball") {
|
||||||
|
return (
|
||||||
|
<CourtBall
|
||||||
|
onMoved={onBallMoved}
|
||||||
|
ball={object}
|
||||||
|
onRemove={onBallRemove}
|
||||||
|
key="ball"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
throw new Error("unknown court object" + object.type)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{previewAction && (
|
||||||
|
<CourtAction
|
||||||
|
courtRef={courtRef}
|
||||||
|
action={previewAction}
|
||||||
|
//do nothing on change, not really possible as it's a preview arrow
|
||||||
|
onActionDeleted={() => {}}
|
||||||
|
onActionChanges={() => {}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,38 @@
|
|||||||
|
import React, { useRef } from "react"
|
||||||
|
import Draggable from "react-draggable"
|
||||||
|
import { BallPiece } from "./BallPiece"
|
||||||
|
import { Ball } from "../../model/tactic/CourtObjects"
|
||||||
|
|
||||||
|
export interface CourtBallProps {
|
||||||
|
onMoved: (rect: DOMRect) => void
|
||||||
|
onRemove: () => void
|
||||||
|
ball: Ball
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CourtBall({ onMoved, ball, onRemove }: CourtBallProps) {
|
||||||
|
const pieceRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const x = ball.rightRatio
|
||||||
|
const y = ball.bottomRatio
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Draggable
|
||||||
|
onStop={() => onMoved(pieceRef.current!.getBoundingClientRect())}
|
||||||
|
nodeRef={pieceRef}>
|
||||||
|
<div
|
||||||
|
className={"ball-div"}
|
||||||
|
ref={pieceRef}
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyUp={(e) => {
|
||||||
|
if (e.key == "Delete") onRemove()
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: `${x * 100}%`,
|
||||||
|
top: `${y * 100}%`,
|
||||||
|
}}>
|
||||||
|
<BallPiece />
|
||||||
|
</div>
|
||||||
|
</Draggable>
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,19 @@
|
|||||||
|
import { Pos } from "../../components/arrows/Pos"
|
||||||
|
import { Segment } from "../../components/arrows/BendableArrow"
|
||||||
|
import { PlayerId } from "./Player"
|
||||||
|
|
||||||
|
export enum ActionKind {
|
||||||
|
SCREEN = "SCREEN",
|
||||||
|
DRIBBLE = "DRIBBLE",
|
||||||
|
MOVE = "MOVE",
|
||||||
|
SHOOT = "SHOOT",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Action = { type: ActionKind } & MovementAction
|
||||||
|
|
||||||
|
export interface MovementAction {
|
||||||
|
fromPlayerId: PlayerId
|
||||||
|
toPlayerId?: PlayerId
|
||||||
|
moveFrom: Pos
|
||||||
|
segments: Segment[]
|
||||||
|
}
|
@ -1,11 +1,17 @@
|
|||||||
|
export type CourtObject = { type: "ball" } & Ball
|
||||||
|
|
||||||
export interface Ball {
|
export interface Ball {
|
||||||
/**
|
/**
|
||||||
* Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle)
|
* The ball is a "ball" court object
|
||||||
*/
|
*/
|
||||||
bottom_percentage: number
|
readonly type: "ball"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle)
|
||||||
|
*/
|
||||||
|
readonly bottomRatio: number
|
||||||
/**
|
/**
|
||||||
* Percentage of the player's position to the right (0 means left, 1 means right, 0.5 means middle)
|
* Percentage of the player's position to the right (0 means left, 1 means right, 0.5 means middle)
|
||||||
*/
|
*/
|
||||||
right_percentage: number
|
readonly rightRatio: number
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,17 @@
|
|||||||
|
export type CourtObject = { type: "ball" } & Ball
|
||||||
|
|
||||||
|
export interface Ball {
|
||||||
|
/**
|
||||||
|
* The ball is a "ball" court object
|
||||||
|
*/
|
||||||
|
readonly type: "ball"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle)
|
||||||
|
*/
|
||||||
|
readonly bottomRatio: number
|
||||||
|
/**
|
||||||
|
* Percentage of the player's position to the right (0 means left, 1 means right, 0.5 means middle)
|
||||||
|
*/
|
||||||
|
readonly rightRatio: number
|
||||||
|
}
|
@ -1,26 +1,35 @@
|
|||||||
import { Team } from "./Team"
|
export type PlayerId = string
|
||||||
|
|
||||||
|
export enum PlayerTeam {
|
||||||
|
Allies = "allies",
|
||||||
|
Opponents = "opponents",
|
||||||
|
}
|
||||||
|
|
||||||
export interface Player {
|
export interface Player {
|
||||||
id: string
|
readonly id: PlayerId
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* the player's team
|
* the player's team
|
||||||
* */
|
* */
|
||||||
team: Team
|
readonly team: PlayerTeam
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* player's role
|
* player's role
|
||||||
* */
|
* */
|
||||||
role: string
|
readonly role: string
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle)
|
* Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle)
|
||||||
*/
|
*/
|
||||||
bottomRatio: number
|
readonly bottomRatio: number
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Percentage of the player's position to the right (0 means left, 1 means right, 0.5 means middle)
|
* Percentage of the player's position to the right (0 means left, 1 means right, 0.5 means middle)
|
||||||
*/
|
*/
|
||||||
rightRatio: number
|
readonly rightRatio: number
|
||||||
|
|
||||||
hasBall: boolean
|
/**
|
||||||
|
* True if the player has a basketball
|
||||||
|
*/
|
||||||
|
readonly hasBall: boolean
|
||||||
}
|
}
|
||||||
|
@ -1,4 +0,0 @@
|
|||||||
export enum Team {
|
|
||||||
Allies = "allies",
|
|
||||||
Opponents = "opponents",
|
|
||||||
}
|
|
@ -0,0 +1,23 @@
|
|||||||
|
.arrow-action {
|
||||||
|
height: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow-action-icon {
|
||||||
|
user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
max-width: 17px;
|
||||||
|
max-height: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow-head-pick {
|
||||||
|
position: absolute;
|
||||||
|
cursor: grab;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
min-width: 17px;
|
||||||
|
min-height: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow-head-pick:active {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
@ -0,0 +1,14 @@
|
|||||||
|
.remove-action {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-action * {
|
||||||
|
stroke: red;
|
||||||
|
fill: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-action:hover * {
|
||||||
|
fill: #f1dbdb;
|
||||||
|
stroke: #ff331a;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
@ -1,20 +0,0 @@
|
|||||||
#court-container {
|
|
||||||
display: flex;
|
|
||||||
align-content: center;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
background-color: var(--main-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
#court-svg {
|
|
||||||
margin: 35px 0 35px 0;
|
|
||||||
height: 87%;
|
|
||||||
user-select: none;
|
|
||||||
-webkit-user-drag: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#court-svg * {
|
|
||||||
stroke: var(--selected-team-secondarycolor);
|
|
||||||
}
|
|
@ -0,0 +1,22 @@
|
|||||||
|
.arrow-point {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
border-radius: 100px;
|
||||||
|
background-color: black;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow-point:hover {
|
||||||
|
background-color: var(--selection-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow-path {
|
||||||
|
pointer-events: stroke;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow-path:hover,
|
||||||
|
.arrow-path:active {
|
||||||
|
stroke: var(--selection-color);
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
:root {
|
||||||
|
--main-color: #ffffff;
|
||||||
|
--second-color: #ccde54;
|
||||||
|
|
||||||
|
--background-color: #d2cdd3;
|
||||||
|
|
||||||
|
--selected-team-primarycolor: #ffffff;
|
||||||
|
--selected-team-secondarycolor: #000000;
|
||||||
|
|
||||||
|
--selection-color: #3f7fc4;
|
||||||
|
|
||||||
|
--arrows-color: #676767;
|
||||||
|
}
|
@ -0,0 +1,58 @@
|
|||||||
|
import { Action, ActionKind } from "../../model/tactic/Action"
|
||||||
|
import BendableArrow from "../../components/arrows/BendableArrow"
|
||||||
|
import { RefObject } from "react"
|
||||||
|
import { MoveToHead, ScreenHead } from "../../components/actions/ArrowAction"
|
||||||
|
|
||||||
|
export interface CourtActionProps {
|
||||||
|
action: Action
|
||||||
|
onActionChanges: (a: Action) => void
|
||||||
|
onActionDeleted: () => void
|
||||||
|
courtRef: RefObject<HTMLElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CourtAction({
|
||||||
|
action,
|
||||||
|
onActionChanges,
|
||||||
|
onActionDeleted,
|
||||||
|
courtRef,
|
||||||
|
}: CourtActionProps) {
|
||||||
|
let head
|
||||||
|
switch (action.type) {
|
||||||
|
case ActionKind.DRIBBLE:
|
||||||
|
case ActionKind.MOVE:
|
||||||
|
case ActionKind.SHOOT:
|
||||||
|
head = () => <MoveToHead />
|
||||||
|
break
|
||||||
|
case ActionKind.SCREEN:
|
||||||
|
head = () => <ScreenHead />
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
let dashArray
|
||||||
|
switch (action.type) {
|
||||||
|
case ActionKind.SHOOT:
|
||||||
|
dashArray = "10 5"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BendableArrow
|
||||||
|
forceStraight={action.type == ActionKind.SHOOT}
|
||||||
|
area={courtRef}
|
||||||
|
startPos={action.moveFrom}
|
||||||
|
segments={action.segments}
|
||||||
|
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}
|
||||||
|
onDeleteRequested={onActionDeleted}
|
||||||
|
style={{
|
||||||
|
head,
|
||||||
|
dashArray,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|