Add arrows to drag and drop on the court

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

@ -1,4 +1,4 @@
<svg width="80" height="49" viewBox="0 0 80 49" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 80 49" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M24.5 4.5H55.5C66.5457 4.5 75.5 13.4543 75.5 24.5C75.5 35.5457 66.5457 44.5 55.5 44.5H24.5C13.4543 44.5 4.5 35.5457 4.5 24.5C4.5 13.4543 13.4543 4.5 24.5 4.5Z"
stroke="black" stroke-width="9"/>
<line x1="24.5" y1="24.5" x2="55.5" y2="24.5" stroke="black" stroke-width="9" stroke-linecap="round"/>

Before

Width:  |  Height:  |  Size: 427 B

After

Width:  |  Height:  |  Size: 405 B

@ -0,0 +1,52 @@
import "../../style/actions/arrow_action.css"
import Draggable from "react-draggable"
import {RefObject, useRef} from "react"
import Xarrow, {useXarrow, Xwrapper} from "react-xarrows"
export interface ArrowActionProps {
originRef: RefObject<HTMLDivElement>
onArrowDropped: (arrowHead: DOMRect) => void
}
export default function ArrowAction({
originRef,
onArrowDropped,
}: ArrowActionProps) {
const arrowHeadRef = useRef<HTMLDivElement>(null)
const updateXarrow = useXarrow()
return (
<div className="arrow-action">
<div className="arrow-action-pin"/>
<Xwrapper>
<Draggable
nodeRef={arrowHeadRef}
onDrag={updateXarrow}
onStop={() => {
const headBounds =
arrowHeadRef.current!.getBoundingClientRect()
updateXarrow()
onArrowDropped(headBounds)
}}
position={{x: 0, y: 0}}>
<div
style={{
position: "absolute",
}}
ref={arrowHeadRef}
className="arrow-head-pick"
onMouseDown={updateXarrow}/>
</Draggable>
<div className={"arrow-head-xarrow"}>
<Xarrow
start={originRef}
end={arrowHeadRef}
startAnchor={"auto"}
/>
</div>
</Xwrapper>
</div>
)
}

@ -0,0 +1,12 @@
import {BallPiece} from "../editor/BallPiece";
export interface BallActionProps {
onDrop: (el: HTMLElement) => void
}
export default function BallAction({onDrop}: BallActionProps) {
return (
<BallPiece onDrop={onDrop} />
)
}

@ -0,0 +1,15 @@
import RemoveIcon from "../../assets/icon/remove.svg?react"
import "../../style/actions/remove_action.css"
export interface RemoveActionProps {
onRemove: () => void
}
export default function RemoveAction({ onRemove }: RemoveActionProps) {
return (
<RemoveIcon
className="remove-action"
onClick={onRemove}
/>
)
}

@ -1,7 +1,8 @@
import React from "react"
import "../../style/ball.css"
import BallSvg from "../../assets/icon/ball.svg?react"
import { Ball } from "../../tactic/CourtObjects"
export function BallPiece() {
return <BallSvg className={"ball"} />

@ -1,16 +1,23 @@
import "../../style/basket_court.css"
import { RefObject } from "react"
import {ReactElement, RefObject} from "react"
import CourtPlayer from "./CourtPlayer"
import {Player} from "../../tactic/Player"
import { CourtObject } from "../../tactic/CourtObjects"
import { CourtBall } from "./CourtBall"
import {Action, MovementActionKind} from "../../tactic/Action"
import RemoveAction from "../actions/RemoveAction"
import ArrowAction from "../actions/ArrowAction"
import {useXarrow} from "react-xarrows"
import BallAction from "../actions/BallAction";
import {CourtObject} from "../../tactic/CourtObjects";
import {CourtBall} from "./CourtBall";
export interface BasketCourtProps {
players: Player[]
actions: Action[]
objects: CourtObject[]
renderAction: (a: Action) => ReactElement
setActions: (f: (a: Action[]) => Action[]) => void
onPlayerRemove: (p: Player) => void
onBallDrop: (ref: HTMLElement) => void
onPlayerChange: (p: Player) => void
onBallRemove: () => void
@ -24,6 +31,10 @@ export interface BasketCourtProps {
export function BasketCourt({
players,
objects,
actions,
renderAction,
setActions,
onBallDrop,
onPlayerRemove,
onBallRemove,
onBallMoved,
@ -31,24 +42,66 @@ export function BasketCourt({
courtImage,
courtRef,
}: BasketCourtProps) {
function bindArrowToPlayer(
originRef: RefObject<HTMLDivElement>,
arrowHead: DOMRect,
) {
for (const player of players) {
if (player.id == originRef.current!.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 action = {
type: MovementActionKind.SCREEN,
moveFrom: originRef.current!.id,
moveTo: player.id,
}
setActions((actions) => [...actions, action])
}
}
}
const updateArrows = useXarrow()
return (
<div
id="court-container"
ref={courtRef}
style={{ position: "relative" }}>
<div id="court-container" ref={courtRef} style={{ position: "relative" }}>
<img src={courtImage} alt={"court"} id="court-svg" />
{players.map((player) => {
return (
{players.map((player) => (
<CourtPlayer
key={player.team + player.role}
key={player.id}
player={player}
onDrag={updateArrows}
onChange={onPlayerChange}
onRemove={() => onPlayerRemove(player)}
onBallDrop={onBallMoved}
parentRef={courtRef}
availableActions={(pieceRef) => [
<RemoveAction
key={1}
onRemove={() => onPlayerRemove(player)}
/>,
<ArrowAction
key={2}
originRef={pieceRef}
onArrowDropped={(headRect) =>
bindArrowToPlayer(pieceRef, headRect)
}
/>,
player.hasBall && <BallAction key={3} onDrop={onBallDrop}/>
]}
/>
)
})}
))}
{objects.map((object) => {
if (object.type == "ball") {
@ -63,6 +116,8 @@ export function BasketCourt({
}
throw new Error("unknown court object", object.type)
})}
{actions.map(renderAction)}
</div>
)
}

@ -1,41 +1,43 @@
import { RefObject, useRef } from "react"
import {ReactNode, RefObject, useRef} from "react"
import "../../style/player.css"
import { BallPiece } from "./BallPiece"
import Draggable from "react-draggable"
import {PlayerPiece} from "./PlayerPiece"
import {Player} from "../../tactic/Player"
import {calculateRatio} from "../../Utils"
export interface PlayerProps {
export interface PlayerProps<A extends ReactNode> {
player: Player
onDrag: () => void,
onChange: (p: Player) => void
onRemove: () => void
onBallDrop: (bounds: DOMRect) => void
parentRef: RefObject<HTMLDivElement>
availableActions: (ro: RefObject<HTMLDivElement>) => A[]
}
/**
* A player that is placed on the court, which can be selected, and moved in the associated bounds
* */
export default function CourtPlayer({
export default function CourtPlayer<A extends ReactNode>({
player,
onDrag,
onChange,
onRemove,
onBallDrop,
parentRef,
}: PlayerProps) {
const pieceRef = useRef<HTMLDivElement>(null)
const ballPiece = useRef<HTMLDivElement>(null)
availableActions,
}: PlayerProps<A>) {
const x = player.rightRatio
const y = player.bottomRatio
const hasBall = player.hasBall
const pieceRef = useRef<HTMLDivElement>(null);
return (
<Draggable
handle={".player-piece"}
handle=".player-piece"
nodeRef={pieceRef}
position={{ x, y }}
onDrag={onDrag}
onStop={() => {
const pieceBounds = pieceRef.current!.getBoundingClientRect()
const parentBounds = parentRef.current!.getBoundingClientRect()
@ -49,44 +51,23 @@ export default function CourtPlayer({
team: player.team,
role: player.role,
hasBall: player.hasBall,
})
} as Player)
}}>
<div
id={player.id}
ref={pieceRef}
className={"player"}
className="player"
style={{
position: "absolute",
left: `${x * 100}%`,
top: `${y * 100}%`,
}}>
<div
id={player.id}
tabIndex={0}
className="player-content"
<div tabIndex={0} className="player-content"
onKeyUp={(e) => {
if (e.key == "Delete") onRemove()
}}>
<div className="player-selection-tab">
{hasBall && (
<Draggable
nodeRef={ballPiece}
onStop={() =>
onBallDrop(
ballPiece.current!.getBoundingClientRect(),
)
}
position={{ x: 0, y: 0 }}>
<div ref={ballPiece}>
<BallPiece />
</div>
</Draggable>
)}
</div>
<PlayerPiece
team={player.team}
text={player.role}
hasBall={hasBall}
/>
<div className="player-actions">{availableActions(pieceRef)}</div>
<PlayerPiece team={player.team} text={player.role} hasBall={hasBall} />
</div>
</div>
</Draggable>

@ -0,0 +1,33 @@
.arrow-action {
height: 50%;
}
.arrow-action-pin, .arrow-head-pick {
position: absolute;
min-width: 10px;
min-height: 10px;
border-radius: 100px;
background-color: red;
cursor: grab;
}
.arrow-head-pick {
background-color: red;
}
.arrow-head-xarrow {
visibility: hidden;
}
.arrow-action:active .arrow-head-xarrow {
visibility: visible;
}
.arrow-action:active .arrow-head-pick {
min-height: unset;
min-width: unset;
width: 0;
height: 0;
}

@ -0,0 +1,14 @@
.remove-action {
height: 100%;
}
.remove-action * {
stroke: red;
fill: white;
}
.remove-action:hover * {
fill: #f1dbdb;
stroke: #ff331a;
cursor: pointer;
}

@ -1,9 +1,3 @@
/**
as the .player div content is translated,
the real .player div position is not were the user can expect.
Disable pointer events to this div as it may overlap on other components
on the court.
*/
.player {
pointer-events: none;
}
@ -42,38 +36,26 @@ on the court.
border-color: var(--player-piece-ball-border-color);
}
.player-selection-tab {
display: none;
.player-actions {
display: flex;
position: absolute;
margin-bottom: -20%;
justify-content: center;
width: fit-content;
transform: translateY(-20px);
}
.player-selection-tab-remove {
flex-direction: row;
justify-content: space-between;
align-content: space-between;
align-items: center;
visibility: hidden;
pointer-events: all;
width: 25px;
height: 17px;
justify-content: center;
}
.player-selection-tab-remove * {
stroke: red;
fill: white;
}
height: 75%;
width: 300%;
margin-bottom: 10%;
transform: translateY(-20px);
.player-selection-tab-remove:hover * {
fill: #f1dbdb;
stroke: #ff331a;
cursor: pointer;
}
.player:focus-within .player-selection-tab {
display: flex;
.player:focus-within .player-actions {
visibility: visible;
pointer-events: all;
}
.player:focus-within .player-piece {

@ -19,5 +19,6 @@
--editor-court-selection-background: #5f8fee;
--editor-court-selection-buttons: #acc4f3;
--player-piece-ball-border-color: #000000;
--text-main-font: "Roboto", sans-serif;
}

@ -0,0 +1,14 @@
import { PlayerId } from "./Player"
export enum MovementActionKind {
SCREEN = "SCREEN",
DRIBBLE = "DRIBBLE",
MOVE = "MOVE",
}
export type Action = {type: MovementActionKind } & MovementAction
export interface MovementAction {
moveFrom: PlayerId
moveTo: PlayerId
}

@ -1,7 +1,10 @@
import { Team } from "./Team"
export type PlayerId = string
export interface Player {
readonly id: string
readonly id: PlayerId,
/**
* the player's team
* */
@ -22,5 +25,8 @@ export interface Player {
*/
readonly rightRatio: number
/**
* True if the player has a basketball
*/
readonly hasBall: boolean
}

@ -1,5 +1,6 @@
import { Player } from "./Player"
import { CourtObject } from "./CourtObjects"
import { Action } from "./Action"
export interface Tactic {
id: number
@ -10,4 +11,5 @@ export interface Tactic {
export interface TacticContent {
players: Player[]
objects: CourtObject[]
actions: Action[]
}

@ -1,11 +1,4 @@
import {
CSSProperties,
Dispatch,
SetStateAction,
useCallback,
useRef,
useState,
} from "react"
import {CSSProperties, Dispatch, SetStateAction, useCallback, useRef, useState,} from "react"
import "../style/editor.css"
import TitleInput from "../components/TitleInput"
import {BasketCourt} from "../components/editor/BasketCourt"
@ -24,10 +17,8 @@ import { fetchAPI } from "../Fetcher"
import {Team} from "../tactic/Team"
import {calculateRatio} from "../Utils"
import SavingState, {
SaveState,
SaveStates,
} from "../components/editor/SavingState"
import SavingState, {SaveState, SaveStates,} from "../components/editor/SavingState"
import {renderAction} from "./editor/ActionsRender"
import {CourtObject} from "../tactic/CourtObjects"
@ -166,6 +157,7 @@ function EditorView({
hasBall: false,
},
],
actions: content.actions,
}
})
}
@ -362,7 +354,7 @@ function EditorView({
}}
/>
</div>
<div id="topbar-right"></div>
<div id="topbar-right"/>
</div>
<div id="edit-div">
<div id="racks">
@ -423,6 +415,14 @@ function EditorView({
courtType == "PLAIN" ? plainCourt : halfCourt
}
courtRef={courtDivContentRef}
actions={content.actions}
setActions={(actions) =>
setContent((c) => ({
players: c.players,
actions: actions(c.actions),
}))
}
renderAction={renderAction}
onPlayerChange={(player) => {
const playerBounds = document
.getElementById(player.id)!
@ -438,14 +438,13 @@ function EditorView({
player,
true,
),
actions: content.actions,
}))
}}
onPlayerRemove={(player) => {
removePlayer(player)
}}
onBallRemove={() => {
removeCourtBall()
}}
onBallRemove={removeCourtBall}
/>
</div>
</div>

@ -0,0 +1,35 @@
import { Action, MovementActionKind } from "../../tactic/Action"
import Xarrow, { Xwrapper } from "react-xarrows"
import { xarrowPropsType } from "react-xarrows/lib/types"
export function renderAction(action: Action) {
const from = action.moveFrom;
const to = action.moveTo;
let arrowStyle: xarrowPropsType = {start: from, end: to, color: "var(--arrows-color)"}
switch (action.type) {
case MovementActionKind.DRIBBLE:
arrowStyle.dashness = {
animation: true,
strokeLen: 5,
nonStrokeLen: 5
}
break
case MovementActionKind.SCREEN:
arrowStyle.headShape = "circle"
arrowStyle.headSize = 2.5
break
case MovementActionKind.MOVE:
}
return (
<Xwrapper key={`${action.type}-${from}-${to}`}>
<Xarrow {...arrowStyle}/>
</Xwrapper>
)
}

@ -13,6 +13,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-draggable": "^4.4.6",
"react-xarrows": "^2.0.2",
"typescript": "^5.2.2",
"vite": "^4.5.0",
"vite-plugin-css-injected-by-js": "^3.3.0"
@ -32,8 +33,8 @@
},
"devDependencies": {
"@vitejs/plugin-react": "^4.1.0",
"vite-plugin-svgr": "^4.1.0",
"prettier": "^3.1.0",
"typescript": "^5.2.2"
"typescript": "^5.2.2",
"vite-plugin-svgr": "^4.1.0"
}
}

@ -19,7 +19,7 @@ CREATE TABLE Tactic
name varchar NOT NULL,
creation_date timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
owner integer NOT NULL,
content varchar DEFAULT '{"players": [], "objects": []}' NOT NULL,
content varchar DEFAULT '{"players": [], "actions": [], "objects": []}' NOT NULL,
court_type varchar CHECK ( court_type IN ('HALF', 'PLAIN')) NOT NULL,
FOREIGN KEY (owner) REFERENCES Account
);

@ -42,7 +42,7 @@ class EditorController {
return ViewHttpResponse::react("views/Editor.tsx", [
"id" => -1, //-1 id means that the editor will not support saves
"name" => TacticModel::TACTIC_DEFAULT_NAME,
"content" => '{"players": [], "objects": []}',
"content" => '{"players": [], "objects": [], "actions": []}',
"courtType" => $courtType->name(),
]);
}

Loading…
Cancel
Save