add head/tails, add control points

pull/82/head
Override-6 1 year ago committed by maxime.batista
parent b98fe575b0
commit 6c92471062

@ -5,14 +5,14 @@ import {useRef} from "react"
export interface ArrowActionProps {
onHeadDropped: (headBounds: DOMRect) => void
onHeadPicked: (headBounds: DOMRect) => void,
onHeadMoved: (headBounds: DOMRect) => void,
onHeadPicked: (headBounds: DOMRect) => void
onHeadMoved: (headBounds: DOMRect) => void
}
export default function ArrowAction({
onHeadDropped,
onHeadPicked,
onHeadMoved
onHeadMoved,
}: ArrowActionProps) {
const arrowHeadRef = useRef<HTMLDivElement>(null)

@ -6,10 +6,5 @@ export interface RemoveActionProps {
}
export default function RemoveAction({ onRemove }: RemoveActionProps) {
return (
<RemoveIcon
className="remove-action"
onClick={onRemove}
/>
)
return <RemoveIcon className="remove-action" onClick={onRemove} />
}

@ -1,27 +1,33 @@
import { CSSProperties, ReactElement, useCallback, useEffect, useRef } from "react"
import { add, angle, Pos, relativeTo, size } from "./Pos"
import {CSSProperties, ReactElement, useCallback, useEffect, useRef, useState,} from "react"
import {angle, middlePos, Pos, relativeTo} from "./Pos"
import "../../style/bendable_arrows.css"
import Draggable from "react-draggable"
export interface BendableArrowProps {
basePos: Pos
startPos: Pos
endPos: Pos
segments: Segment[]
onSegmentsChanges: (edges: Segment[]) => void
startRadius?: number
endRadius?: number
style?: ArrowStyle
}
export interface ArrowStyle {
width?: number,
head?: () => ReactElement,
tail?: () => ReactElement,
width?: number
head?: () => ReactElement
tail?: () => ReactElement
}
const ArrowStyleDefaults = {
width: 4
width: 4,
}
export interface Segment {
next: Pos
controlPoint?: Pos
}
function constraintInCircle(pos: Pos, from: Pos, radius: number): Pos {
@ -29,66 +35,279 @@ function constraintInCircle(pos: Pos, from: Pos, radius: number): Pos {
return {
x: pos.x - Math.sin(theta) * radius,
y: pos.y - Math.cos(theta) * radius
y: pos.y - Math.cos(theta) * radius,
}
}
export default function BendableArrow({ basePos, startPos, endPos, style, startRadius = 0, endRadius = 0 }: BendableArrowProps) {
function Triangle({fill}: {fill: string}) {
return (
<svg viewBox={"0 0 50 50"} width={20} height={20}>
<polygon points={"50 0, 0 0, 25 40"} fill={fill}/>
</svg>
)
}
export default function BendableArrow({
startPos,
segments,
onSegmentsChanges,
style,
startRadius = 0,
endRadius = 0,
}: BendableArrowProps) {
const containerRef = useRef<HTMLDivElement>(null)
const svgRef = useRef<SVGSVGElement>(null)
const pathRef = useRef<SVGPathElement>(null);
const pathRef = useRef<SVGPathElement>(null)
const styleWidth = style?.width ?? ArrowStyleDefaults.width
const [controlPointsDots, setControlPointsDots] = useState<ReactElement[]>(
[],
)
useEffect(() => {
setInternalSegments(segments)
}, [segments]);
const [internalSegments, setInternalSegments] = useState(segments)
const [isSelected, setIsSelected] = useState(false)
const headRef = useRef<HTMLDivElement>(null)
const tailRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const basePos = containerRef.current!.parentElement!.getBoundingClientRect()
setControlPointsDots(computeControlPoints(basePos))
const selectionHandler = (e: MouseEvent) => {
if (!(e.target instanceof Node))
return
setIsSelected(containerRef.current!.contains(e.target))
}
document.addEventListener('mousedown', selectionHandler)
return () => document.removeEventListener('mousedown', selectionHandler)
}, []);
function computeControlPoints(basePos: Pos) {
return internalSegments.map(({next, controlPoint}, i) => {
const prev = i == 0 ? startPos : internalSegments[i - 1].next
const prevRelative = relativeTo(prev, basePos)
const nextRelative = relativeTo(next, basePos)
const cpPos = controlPoint ? relativeTo(controlPoint, basePos) : {
x: prevRelative.x / 2 + nextRelative.x / 2,
y: prevRelative.y / 2 + nextRelative.y / 2,
}
return (
<ControlPoint
key={i}
pos={cpPos}
basePos={basePos}
onPosValidated={(controlPoint) => {
const segment = internalSegments[i]
const segments = internalSegments.toSpliced(i, 1, {...segment, controlPoint})
onSegmentsChanges(segments)
}}
onMoves={(controlPoint) => {
setInternalSegments(is => {
return is.toSpliced(i, 1, {...is[i], controlPoint})
})
}}
/>
)
})
}
const update = useCallback(() => {
// only one segment is supported for now, which is the first.
// any other segments will be ignored
const segment = internalSegments[0] ?? null
if (segment == null) throw new Error("segments might not be empty.")
const endPos = segment.next
const basePos = containerRef.current!.parentElement!.getBoundingClientRect()
const update = () => {
const startRelative = relativeTo(startPos, basePos)
const endRelative = relativeTo(endPos, basePos)
const endRelative = relativeTo(endPos!, basePos)
const tailPos = constraintInCircle(startRelative, endRelative, startRadius)
const headPos = constraintInCircle(endRelative, startRelative, endRadius)
const controlPoint = segment.controlPoint ? relativeTo(segment.controlPoint, basePos) : {
x: startRelative.x / 2 + endRelative.x / 2,
y: startRelative.y / 2 + endRelative.y / 2,
}
// the width and height of the arrow svg
const svgBoxBounds = size(startPos, endPos)
const tailPos = constraintInCircle(
startRelative,
controlPoint,
startRadius!,
)
const headPos = constraintInCircle(
endRelative,
controlPoint,
endRadius!,
)
const left = Math.min(tailPos.x, headPos.x)
const top = Math.min(tailPos.y, headPos.y)
const svgStyle: CSSProperties = {
width: `${svgBoxBounds.x}px`,
height: `${svgBoxBounds.y}px`,
Object.assign(tailRef.current!.style, {
left: tailPos.x + "px",
top: tailPos.y + "px",
transformOrigin: "top center",
transform: `translateX(-50%) rotate(${-angle(tailPos, controlPoint) * (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, controlPoint) * (180 / Math.PI)}deg)`
} as CSSProperties)
left: `${left}px`,
top: `${top}px`,
const svgStyle: CSSProperties = {
left: left + "px",
top: top + "px",
}
const segmentsRelatives = internalSegments.map(({next, controlPoint}) => {
return {
next: relativeTo(next, basePos),
cp: controlPoint ? relativeTo(controlPoint, basePos) : undefined
}
})
const d = `M${tailPos.x - left} ${tailPos.y - top} L${headPos.x - left} ${headPos.y - top}`
pathRef.current!.setAttribute("d", d)
const computedSegments = segmentsRelatives
.map(({next: n, cp}, idx) => {
let next = n
if (idx == internalSegments.length - 1) {
//if it is the last element
next = constraintInCircle(
next,
controlPoint,
endRadius!,
)
}
if (cp == undefined) {
return `L${next.x - left} ${next.y - top}`
}
return `C${cp.x - left} ${cp.y - top}, ${cp.x - left} ${cp.y - top}, ${next.x - left} ${next.y - top}`
})
.join(" ")
const d = `M${tailPos.x - left} ${tailPos.y - top} ` + computedSegments
pathRef.current!.setAttribute("d", d)
Object.assign(svgRef.current!.style, svgStyle)
}
useEffect(() => {
//update on resize
window.addEventListener('resize', update)
if (isSelected) {
setControlPointsDots(computeControlPoints(basePos))
}
}, [startPos, internalSegments])
return () => window.removeEventListener('resize', update)
}, [svgRef, basePos, startPos, endPos])
//update on position changes
useEffect(update, [svgRef, basePos, startPos, endPos])
useEffect(update, [update])
return (
<svg ref={svgRef} style={{
overflow: "visible",
position: "absolute",
}}>
<path
ref={pathRef}
stroke={"#000"}
strokeWidth={styleWidth} />
</svg>
<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}
fill="none"
/>
</svg>
<div className={"arrow-head"}
style={{position: "absolute", transformOrigin: "center"}}
ref={headRef}
>
{style?.head?.call(style) ?? <Triangle fill={"red"}/>}
</div>
<div className={"arrow-tail"}
style={{position: "absolute", transformOrigin: "center"}}
ref={tailRef}
>
{style?.tail?.call(style) ?? <Triangle fill={"blue"}/>}
</div>
{isSelected && controlPointsDots}
</div>
)
}
interface ControlPointProps {
pos: Pos
basePos: Pos,
onMoves: (currentPos: Pos) => void
onPosValidated: (newPos: Pos) => void,
radius?: number
}
function ControlPoint({
pos,
onMoves,
onPosValidated,
radius = 7,
}: ControlPointProps) {
const ref = useRef<HTMLDivElement>(null)
return (
<Draggable
nodeRef={ref}
onStop={() => {
const pointPos = middlePos(ref.current!.getBoundingClientRect())
onPosValidated(pointPos)
}}
onDrag={() => {
const pointPos = middlePos(ref.current!.getBoundingClientRect())
onMoves(pointPos)
}}
position={{x: pos.x - radius, y: pos.y - radius}}
>
<div
ref={ref}
className={"arrow-edge-control-point"}
style={{
position: "absolute",
width: radius * 2,
height: radius * 2,
}}
/>
</Draggable>
)
}

@ -3,7 +3,7 @@ export interface Pos {
y: number
}
export const NULL_POS: Pos = { x: 0, y: 0 }
export const NULL_POS: Pos = {x: 0, y: 0}
/**
* Returns position of a relative to b
@ -11,7 +11,7 @@ export const NULL_POS: Pos = { x: 0, y: 0 }
* @param b
*/
export function relativeTo(a: Pos, b: Pos): Pos {
return { x: a.x - b.x, y: a.y - b.y }
return {x: a.x - b.x, y: a.y - b.y}
}
/**
@ -19,7 +19,7 @@ export function relativeTo(a: Pos, b: Pos): Pos {
* @param rect
*/
export function middlePos(rect: DOMRect): Pos {
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }
return {x: rect.x + rect.width / 2, y: rect.y + rect.height / 2}
}
/**
@ -28,14 +28,14 @@ export function middlePos(rect: DOMRect): Pos {
* @param b
*/
export function size(a: Pos, b: Pos): Pos {
return { x: Math.abs(a.x - b.x), y: Math.abs(a.y - b.y) }
return {x: Math.abs(a.x - b.x), y: Math.abs(a.y - b.y)}
}
export function add(a: Pos, b: Pos): Pos {
return { x: a.x + b.x, y: a.y + b.y }
return {x: a.x + b.x, y: a.y + b.y}
}
export function angle(a: Pos, b: Pos): number {
const r = relativeTo(a, b)
return Math.atan2(r.x, r.y)
}
}

@ -1,25 +1,25 @@
import "../../style/basket_court.css"
import {CourtBall} from "./CourtBall";
import {ReactElement, RefObject, useState} from "react"
import {ReactElement, RefObject, useCallback, useState,} from "react"
import CourtPlayer from "./CourtPlayer"
import {Player} from "../../tactic/Player"
import {Action, MovementActionKind} from "../../tactic/Action"
import RemoveAction from "../actions/RemoveAction"
import ArrowAction from "../actions/ArrowAction"
import {useXarrow} from "react-xarrows"
import BendableArrow from "../arrows/BendableArrow"
import BendableArrow, {Segment} from "../arrows/BendableArrow"
import {middlePos, NULL_POS, Pos} from "../arrows/Pos"
import {CourtObject} from "../../tactic/CourtObjects";
import {CourtBall} from "./CourtBall";
import BallAction from "../actions/BallAction";
import {CourtObject} from "../../tactic/CourtObjects";
export interface BasketCourtProps {
players: Player[]
actions: Action[]
objects: CourtObject[]
renderAction: (courtBounds: DOMRect, a: Action, idx: number) => ReactElement
renderAction: (a: Action, key: number) => ReactElement
setActions: (f: (a: Action[]) => Action[]) => void
onPlayerRemove: (p: Player) => void
onPlayerChange: (p: Player) => void
@ -64,35 +64,55 @@ export function BasketCourt({
playerBounds.left > arrowHead.right
)
) {
const action = {
const targetPos = document.getElementById(player.id)!.getBoundingClientRect()
const action: Action = {
fromPlayerId: originRef.id,
toPlayerId: player.id,
type: MovementActionKind.SCREEN,
moveFrom: originRef.id,
moveTo: player.id,
moveFrom: middlePos(originRef.getBoundingClientRect()),
segments: [{next: middlePos(targetPos)}],
}
setActions((actions) => [...actions, action])
}
}
}
const updateArrows = useXarrow()
const [previewArrowOriginPos, setPreviewArrowOriginPos] =
useState<Pos>(NULL_POS)
const [previewArrowEndPos, setPreviewArrowEndPos] = useState<Pos>(NULL_POS)
const [isPreviewArrowEnabled, setPreviewArrowEnabled] = useState(false)
const [previewArrowEdges, setPreviewArrowEdges] = useState<Segment[]>([])
const updateActionsRelatedTo = useCallback((player: Player) => {
const newPos = middlePos(document.getElementById(player.id)!.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
}))
}, [])
return (
<div id="court-container" ref={courtRef} style={{position: "relative"}}>
<img src={courtImage} alt={"court"} id="court-svg"/>
{actions.map((action, idx) => renderAction(courtRef.current!.getBoundingClientRect(), action, idx))}
{actions.map((action, idx) => renderAction(action, idx))}
{players.map((player) => (
<CourtPlayer
key={player.id}
player={player}
onDrag={updateArrows}
onDrag={() => updateActionsRelatedTo(player)}
onChange={onPlayerChange}
onRemove={() => onPlayerRemove(player)}
parentRef={courtRef}
@ -104,18 +124,22 @@ export function BasketCourt({
<ArrowAction
key={2}
onHeadMoved={(headPos) =>
setPreviewArrowEndPos(middlePos(headPos))
setPreviewArrowEdges([
{next: middlePos(headPos)},
])
}
onHeadPicked={(headRef) => {
onHeadPicked={(headPos) => {
setPreviewArrowOriginPos(
middlePos(pieceRef.getBoundingClientRect()),
)
setPreviewArrowEndPos(middlePos(headRef))
setPreviewArrowEdges([
{next: middlePos(headPos)},
])
setPreviewArrowEnabled(true)
}}
onHeadDropped={(headRect) => {
setPreviewArrowEnabled(false)
bindArrowToPlayer(pieceRef, headRect)
setPreviewArrowEnabled(false)
}}
/>,
player.hasBall && <BallAction key={3} onDrop={ref => onBallMoved(ref.getBoundingClientRect())}/>
@ -139,9 +163,12 @@ export function BasketCourt({
{isPreviewArrowEnabled && (
<BendableArrow
basePos={courtRef.current!.getBoundingClientRect()}
startPos={previewArrowOriginPos}
endPos={previewArrowEndPos}
segments={previewArrowEdges}
//do nothing on change, not really possible as it's a preview arrow
onSegmentsChanges={() => {}}
endRadius={17}
startRadius={26}
/>
)}
</div>

@ -8,7 +8,7 @@ import {calculateRatio} from "../../Utils"
export interface PlayerProps<A extends ReactNode> {
player: Player
onDrag: () => void,
onDrag: () => void
onChange: (p: Player) => void
onRemove: () => void
parentRef: RefObject<HTMLElement>
@ -30,7 +30,7 @@ export default function CourtPlayer<A extends ReactNode>({
const y = player.bottomRatio
const hasBall = player.hasBall
const pieceRef = useRef<HTMLDivElement>(null);
const pieceRef = useRef<HTMLDivElement>(null)
return (
<Draggable

@ -1,10 +1,9 @@
.arrow-action {
height: 50%;
}
.arrow-action-pin, .arrow-head-pick {
.arrow-action-pin,
.arrow-head-pick {
position: absolute;
min-width: 10px;
min-height: 10px;
@ -30,4 +29,4 @@
/* min-width: unset;*/
/* width: 0;*/
/* height: 0;*/
/*}*/
/*}*/

@ -11,4 +11,4 @@
fill: #f1dbdb;
stroke: #ff331a;
cursor: pointer;
}
}

@ -0,0 +1,20 @@
.arrow-edge-control-point {
cursor: pointer;
border-radius: 100px;
background-color: black;
}
.arrow-edge-control-point:hover {
background-color: var(--selection-color);
}
.arrow-path {
pointer-events: stroke;
cursor: pointer;
}
.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;
}

@ -46,11 +46,12 @@
align-items: center;
visibility: hidden;
height: 75%;
width: 300%;
margin-bottom: 10%;
transform: translateY(-20px);
height: 20px;
gap: 25%;
}
.player:focus-within .player-actions {

@ -1,3 +1,5 @@
import { Pos } from "../components/arrows/Pos"
import { Segment } from "../components/arrows/BendableArrow"
import { PlayerId } from "./Player"
export enum MovementActionKind {
@ -6,9 +8,11 @@ export enum MovementActionKind {
MOVE = "MOVE",
}
export type Action = {type: MovementActionKind } & MovementAction
export type Action = { type: MovementActionKind } & MovementAction
export interface MovementAction {
moveFrom: PlayerId
moveTo: PlayerId
fromPlayerId: PlayerId,
toPlayerId: PlayerId,
moveFrom: Pos
segments: Segment[]
}

@ -3,7 +3,8 @@ import { Team } from "./Team"
export type PlayerId = string
export interface Player {
readonly id: PlayerId,
readonly id: PlayerId
/**
* the player's team

@ -1,8 +1,6 @@
import {CSSProperties, Dispatch, SetStateAction, useCallback, useRef, useState,} from "react"
import {CSSProperties, Dispatch, SetStateAction, useCallback, useMemo, useRef, useState,} from "react"
import "../style/editor.css"
import TitleInput from "../components/TitleInput"
import {BasketCourt} from "../components/editor/BasketCourt"
import plainCourt from "../assets/court/full_court.svg"
import halfCourt from "../assets/court/half_court.svg"
@ -18,9 +16,10 @@ import {Team} from "../tactic/Team"
import {calculateRatio} from "../Utils"
import SavingState, {SaveState, SaveStates,} from "../components/editor/SavingState"
import {ActionRender} from "./editor/ActionsRender"
import {CourtObject} from "../tactic/CourtObjects"
import {CourtAction} from "./editor/CourtAction"
import {BasketCourt} from "../components/editor/BasketCourt";
const ERROR_STYLE: CSSProperties = {
borderColor: "red",
@ -109,7 +108,11 @@ function EditorView({
const [content, setContent, saveState] = useContentState(
initialContent,
isInGuestMode ? SaveStates.Guest : SaveStates.Ok,
onContentChange,
useMemo(() => debounceAsync((content) =>
onContentChange(content).then((success) =>
success ? SaveStates.Ok : SaveStates.Err,
)
, 250), [onContentChange])
)
const [allies, setAllies] = useState(
@ -300,6 +303,7 @@ function EditorView({
...content,
players: toSplicedPlayers(content.players, player, false),
objects: [...content.objects],
actions: content.actions.filter(a => a.toPlayerId !== player.id && a.fromPlayerId !== player.id),
}))
let setter
switch (player.team) {
@ -423,7 +427,18 @@ function EditorView({
actions: actions(content.actions),
}))
}
renderAction={(basePos, action, idx) => <ActionRender key={idx} basePos={basePos} action={action}/>}
renderAction={(action, i) => (
<CourtAction
key={i}
action={action}
onActionChanges={(a) =>
setContent((content) => ({
...content,
actions: content.actions.toSpliced(i, 1, a),
}))
}
/>
)}
onPlayerChange={(player) => {
const playerBounds = document
.getElementById(player.id)!
@ -439,12 +454,9 @@ function EditorView({
player,
true,
),
actions: content.actions,
}))
}}
onPlayerRemove={(player) => {
removePlayer(player)
}}
onPlayerRemove={removePlayer}
onBallRemove={removeCourtBall}
/>
</div>
@ -478,6 +490,16 @@ function getRackPlayers(team: Team, players: Player[]): RackedPlayer[] {
.map((key) => ({ team, key }))
}
function debounceAsync<A, B>(f: (args: A) => Promise<B>, delay = 1000): (args: A) => Promise<B> {
let task = 0;
return (args: A) => {
clearTimeout(task)
return new Promise(resolve => {
task = setTimeout(() => f(args).then(resolve), delay)
})
}
}
function useContentState<S>(
initialContent: S,
initialSaveState: SaveState,

@ -1,24 +0,0 @@
import { Action, MovementActionKind } from "../../tactic/Action"
import Xarrow, { Xwrapper } from "react-xarrows"
import { xarrowPropsType } from "react-xarrows/lib/types"
import BendableArrow from "../../components/arrows/BendableArrow"
import { middlePos, Pos } from "../../components/arrows/Pos"
export function ActionRender({basePos, action}: {basePos: Pos, action: Action}) {
const from = action.moveFrom;
const to = action.moveTo;
const fromPos = document.getElementById(from)!.getBoundingClientRect()
const toPos = document.getElementById(to)!.getBoundingClientRect()
return (
<BendableArrow
key={`${action.type}-${from}-${to}`}
basePos={basePos}
startPos={middlePos(fromPos)}
endPos={middlePos(toPos)}
/>
)
}

@ -0,0 +1,25 @@
import {Action} from "../../tactic/Action"
import BendableArrow from "../../components/arrows/BendableArrow"
export interface CourtActionProps {
action: Action
onActionChanges: (a: Action) => void
}
export function CourtAction({
action,
onActionChanges,
}: CourtActionProps) {
return (
<BendableArrow
startPos={action.moveFrom}
segments={action.segments}
onSegmentsChanges={(edges) => {
onActionChanges({...action, segments: edges})
}}
endRadius={26}
startRadius={26}
/>
)
}

@ -30,6 +30,7 @@
height: 100%;
width: 100%;
margin: 0;
overflow: hidden;
}
</style>

Loading…
Cancel
Save