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" <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"/> 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"/> <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 "../../style/ball.css"
import BallSvg from "../../assets/icon/ball.svg?react" import BallSvg from "../../assets/icon/ball.svg?react"
import { Ball } from "../../tactic/CourtObjects"
export function BallPiece() { export function BallPiece() {
return <BallSvg className={"ball"} /> return <BallSvg className={"ball"} />

@ -1,16 +1,23 @@
import "../../style/basket_court.css" import "../../style/basket_court.css"
import { RefObject } from "react" import {ReactElement, RefObject} from "react"
import CourtPlayer from "./CourtPlayer" import CourtPlayer from "./CourtPlayer"
import {Player} from "../../tactic/Player"
import { Player } from "../../tactic/Player" import {Action, MovementActionKind} from "../../tactic/Action"
import { CourtObject } from "../../tactic/CourtObjects" import RemoveAction from "../actions/RemoveAction"
import { CourtBall } from "./CourtBall" 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 { export interface BasketCourtProps {
players: Player[] players: Player[]
actions: Action[]
objects: CourtObject[] objects: CourtObject[]
renderAction: (a: Action) => ReactElement
setActions: (f: (a: Action[]) => Action[]) => void
onPlayerRemove: (p: Player) => void onPlayerRemove: (p: Player) => void
onBallDrop: (ref: HTMLElement) => void
onPlayerChange: (p: Player) => void onPlayerChange: (p: Player) => void
onBallRemove: () => void onBallRemove: () => void
@ -24,6 +31,10 @@ export interface BasketCourtProps {
export function BasketCourt({ export function BasketCourt({
players, players,
objects, objects,
actions,
renderAction,
setActions,
onBallDrop,
onPlayerRemove, onPlayerRemove,
onBallRemove, onBallRemove,
onBallMoved, onBallMoved,
@ -31,24 +42,66 @@ export function BasketCourt({
courtImage, courtImage,
courtRef, courtRef,
}: BasketCourtProps) { }: 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 ( return (
<div <div id="court-container" ref={courtRef} style={{ position: "relative" }}>
id="court-container"
ref={courtRef}
style={{ position: "relative" }}>
<img src={courtImage} alt={"court"} id="court-svg" /> <img src={courtImage} alt={"court"} id="court-svg" />
{players.map((player) => { {players.map((player) => (
return ( <CourtPlayer
<CourtPlayer key={player.id}
key={player.team + player.role} player={player}
player={player} onDrag={updateArrows}
onChange={onPlayerChange} onChange={onPlayerChange}
onRemove={() => onPlayerRemove(player)} onRemove={() => onPlayerRemove(player)}
onBallDrop={onBallMoved} parentRef={courtRef}
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) => { {objects.map((object) => {
if (object.type == "ball") { if (object.type == "ball") {
@ -63,6 +116,8 @@ export function BasketCourt({
} }
throw new Error("unknown court object", object.type) throw new Error("unknown court object", object.type)
})} })}
{actions.map(renderAction)}
</div> </div>
) )
} }

@ -1,41 +1,43 @@
import { RefObject, useRef } from "react"
import {ReactNode, RefObject, useRef} from "react"
import "../../style/player.css" import "../../style/player.css"
import { BallPiece } from "./BallPiece"
import Draggable from "react-draggable" import Draggable from "react-draggable"
import { PlayerPiece } from "./PlayerPiece" import {PlayerPiece} from "./PlayerPiece"
import { Player } from "../../tactic/Player" import {Player} from "../../tactic/Player"
import { calculateRatio } from "../../Utils" import {calculateRatio} from "../../Utils"
export interface PlayerProps { export interface PlayerProps<A extends ReactNode> {
player: Player player: Player
onDrag: () => void,
onChange: (p: Player) => void onChange: (p: Player) => void
onRemove: () => void onRemove: () => void
onBallDrop: (bounds: DOMRect) => void
parentRef: RefObject<HTMLDivElement> 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 * 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, player,
onDrag,
onChange, onChange,
onRemove, onRemove,
onBallDrop,
parentRef, parentRef,
}: PlayerProps) { availableActions,
const pieceRef = useRef<HTMLDivElement>(null) }: PlayerProps<A>) {
const ballPiece = useRef<HTMLDivElement>(null)
const x = player.rightRatio const x = player.rightRatio
const y = player.bottomRatio const y = player.bottomRatio
const hasBall = player.hasBall const hasBall = player.hasBall
const pieceRef = useRef<HTMLDivElement>(null);
return ( return (
<Draggable <Draggable
handle={".player-piece"} handle=".player-piece"
nodeRef={pieceRef} nodeRef={pieceRef}
position={{ x, y }} position={{ x, y }}
onDrag={onDrag}
onStop={() => { onStop={() => {
const pieceBounds = pieceRef.current!.getBoundingClientRect() const pieceBounds = pieceRef.current!.getBoundingClientRect()
const parentBounds = parentRef.current!.getBoundingClientRect() const parentBounds = parentRef.current!.getBoundingClientRect()
@ -49,44 +51,23 @@ export default function CourtPlayer({
team: player.team, team: player.team,
role: player.role, role: player.role,
hasBall: player.hasBall, hasBall: player.hasBall,
}) } as Player)
}}> }}>
<div <div
id={player.id}
ref={pieceRef} ref={pieceRef}
className={"player"} className="player"
style={{ style={{
position: "absolute", position: "absolute",
left: `${x * 100}%`, left: `${x * 100}%`,
top: `${y * 100}%`, top: `${y * 100}%`,
}}> }}>
<div <div tabIndex={0} className="player-content"
id={player.id} onKeyUp={(e) => {
tabIndex={0} if (e.key == "Delete") onRemove()
className="player-content" }}>
onKeyUp={(e) => { <div className="player-actions">{availableActions(pieceRef)}</div>
if (e.key == "Delete") onRemove() <PlayerPiece team={player.team} text={player.role} hasBall={hasBall} />
}}>
<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> </div>
</div> </div>
</Draggable> </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 { .player {
pointer-events: none; pointer-events: none;
} }
@ -42,38 +36,26 @@ on the court.
border-color: var(--player-piece-ball-border-color); border-color: var(--player-piece-ball-border-color);
} }
.player-selection-tab { .player-actions {
display: none; display: flex;
position: absolute; position: absolute;
margin-bottom: -20%; flex-direction: row;
justify-content: center; justify-content: space-between;
align-content: space-between;
width: fit-content; align-items: center;
transform: translateY(-20px);
}
.player-selection-tab-remove {
visibility: hidden; visibility: hidden;
pointer-events: all;
width: 25px;
height: 17px;
justify-content: center;
}
.player-selection-tab-remove * { height: 75%;
stroke: red; width: 300%;
fill: white; margin-bottom: 10%;
} transform: translateY(-20px);
.player-selection-tab-remove:hover * {
fill: #f1dbdb;
stroke: #ff331a;
cursor: pointer;
} }
.player:focus-within .player-selection-tab { .player:focus-within .player-actions {
display: flex; visibility: visible;
pointer-events: all;
} }
.player:focus-within .player-piece { .player:focus-within .player-piece {

@ -19,5 +19,6 @@
--editor-court-selection-background: #5f8fee; --editor-court-selection-background: #5f8fee;
--editor-court-selection-buttons: #acc4f3; --editor-court-selection-buttons: #acc4f3;
--player-piece-ball-border-color: #000000;
--text-main-font: "Roboto", sans-serif; --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" import { Team } from "./Team"
export type PlayerId = string
export interface Player { export interface Player {
readonly id: string readonly id: PlayerId,
/** /**
* the player's team * the player's team
* */ * */
@ -22,5 +25,8 @@ export interface Player {
*/ */
readonly rightRatio: number readonly rightRatio: number
/**
* True if the player has a basketball
*/
readonly hasBall: boolean readonly hasBall: boolean
} }

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

@ -1,35 +1,26 @@
import { import {CSSProperties, Dispatch, SetStateAction, useCallback, useRef, useState,} from "react"
CSSProperties,
Dispatch,
SetStateAction,
useCallback,
useRef,
useState,
} from "react"
import "../style/editor.css" import "../style/editor.css"
import TitleInput from "../components/TitleInput" import TitleInput from "../components/TitleInput"
import { BasketCourt } from "../components/editor/BasketCourt" import {BasketCourt} from "../components/editor/BasketCourt"
import plainCourt from "../assets/court/full_court.svg" import plainCourt from "../assets/court/full_court.svg"
import halfCourt from "../assets/court/half_court.svg" import halfCourt from "../assets/court/half_court.svg"
import { BallPiece } from "../components/editor/BallPiece" import {BallPiece} from "../components/editor/BallPiece"
import { Rack } from "../components/Rack" import {Rack} from "../components/Rack"
import { PlayerPiece } from "../components/editor/PlayerPiece" import {PlayerPiece} from "../components/editor/PlayerPiece"
import { Player } from "../tactic/Player" import {Player} from "../tactic/Player"
import { Tactic, TacticContent } from "../tactic/Tactic" import {Tactic, TacticContent} from "../tactic/Tactic"
import { fetchAPI } from "../Fetcher" import {fetchAPI} from "../Fetcher"
import { Team } from "../tactic/Team" import {Team} from "../tactic/Team"
import { calculateRatio } from "../Utils" import {calculateRatio} from "../Utils"
import SavingState, { import SavingState, {SaveState, SaveStates,} from "../components/editor/SavingState"
SaveState, import {renderAction} from "./editor/ActionsRender"
SaveStates,
} from "../components/editor/SavingState"
import { CourtObject } from "../tactic/CourtObjects" import {CourtObject} from "../tactic/CourtObjects"
const ERROR_STYLE: CSSProperties = { const ERROR_STYLE: CSSProperties = {
borderColor: "red", borderColor: "red",
@ -166,6 +157,7 @@ function EditorView({
hasBall: false, hasBall: false,
}, },
], ],
actions: content.actions,
} }
}) })
} }
@ -362,7 +354,7 @@ function EditorView({
}} }}
/> />
</div> </div>
<div id="topbar-right"></div> <div id="topbar-right"/>
</div> </div>
<div id="edit-div"> <div id="edit-div">
<div id="racks"> <div id="racks">
@ -423,6 +415,14 @@ function EditorView({
courtType == "PLAIN" ? plainCourt : halfCourt courtType == "PLAIN" ? plainCourt : halfCourt
} }
courtRef={courtDivContentRef} courtRef={courtDivContentRef}
actions={content.actions}
setActions={(actions) =>
setContent((c) => ({
players: c.players,
actions: actions(c.actions),
}))
}
renderAction={renderAction}
onPlayerChange={(player) => { onPlayerChange={(player) => {
const playerBounds = document const playerBounds = document
.getElementById(player.id)! .getElementById(player.id)!
@ -438,14 +438,13 @@ function EditorView({
player, player,
true, true,
), ),
actions: content.actions,
})) }))
}} }}
onPlayerRemove={(player) => { onPlayerRemove={(player) => {
removePlayer(player) removePlayer(player)
}} }}
onBallRemove={() => { onBallRemove={removeCourtBall}
removeCourtBall()
}}
/> />
</div> </div>
</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": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-draggable": "^4.4.6", "react-draggable": "^4.4.6",
"react-xarrows": "^2.0.2",
"typescript": "^5.2.2", "typescript": "^5.2.2",
"vite": "^4.5.0", "vite": "^4.5.0",
"vite-plugin-css-injected-by-js": "^3.3.0" "vite-plugin-css-injected-by-js": "^3.3.0"
@ -32,8 +33,8 @@
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^4.1.0", "@vitejs/plugin-react": "^4.1.0",
"vite-plugin-svgr": "^4.1.0",
"prettier": "^3.1.0", "prettier": "^3.1.0",
"typescript": "^5.2.2" "typescript": "^5.2.2",
"vite-plugin-svgr": "^4.1.0"
} }
} }

@ -16,10 +16,10 @@ CREATE TABLE Account
CREATE TABLE Tactic CREATE TABLE Tactic
( (
id integer PRIMARY KEY AUTOINCREMENT, id integer PRIMARY KEY AUTOINCREMENT,
name varchar NOT NULL, name varchar NOT NULL,
creation_date timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, creation_date timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
owner integer 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, court_type varchar CHECK ( court_type IN ('HALF', 'PLAIN')) NOT NULL,
FOREIGN KEY (owner) REFERENCES Account FOREIGN KEY (owner) REFERENCES Account
); );

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

Loading…
Cancel
Save