diff --git a/front/assets/icon/remove.svg b/front/assets/icon/remove.svg index 6886097..29aec4e 100644 --- a/front/assets/icon/remove.svg +++ b/front/assets/icon/remove.svg @@ -1,4 +1,4 @@ - + diff --git a/front/components/actions/ArrowAction.tsx b/front/components/actions/ArrowAction.tsx new file mode 100644 index 0000000..e52670e --- /dev/null +++ b/front/components/actions/ArrowAction.tsx @@ -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 + onArrowDropped: (arrowHead: DOMRect) => void +} + +export default function ArrowAction({ + originRef, + onArrowDropped, + }: ArrowActionProps) { + const arrowHeadRef = useRef(null) + const updateXarrow = useXarrow() + + return ( +
+
+ + + { + const headBounds = + arrowHeadRef.current!.getBoundingClientRect() + updateXarrow() + onArrowDropped(headBounds) + }} + position={{x: 0, y: 0}}> +
+ + +
+ +
+ +
+ ) +} diff --git a/front/components/actions/BallAction.tsx b/front/components/actions/BallAction.tsx new file mode 100644 index 0000000..a18659e --- /dev/null +++ b/front/components/actions/BallAction.tsx @@ -0,0 +1,12 @@ +import {BallPiece} from "../editor/BallPiece"; + + +export interface BallActionProps { + onDrop: (el: HTMLElement) => void +} + +export default function BallAction({onDrop}: BallActionProps) { + return ( + + ) +} \ No newline at end of file diff --git a/front/components/actions/RemoveAction.tsx b/front/components/actions/RemoveAction.tsx new file mode 100644 index 0000000..1992453 --- /dev/null +++ b/front/components/actions/RemoveAction.tsx @@ -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 ( + + ) +} diff --git a/front/components/editor/BallPiece.tsx b/front/components/editor/BallPiece.tsx index b09f811..c28b71b 100644 --- a/front/components/editor/BallPiece.tsx +++ b/front/components/editor/BallPiece.tsx @@ -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 diff --git a/front/components/editor/BasketCourt.tsx b/front/components/editor/BasketCourt.tsx index 4545f9a..89b53e9 100644 --- a/front/components/editor/BasketCourt.tsx +++ b/front/components/editor/BasketCourt.tsx @@ -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 {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 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, + 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 ( -
+
{"court"} - {players.map((player) => { - return ( - onPlayerRemove(player)} - onBallDrop={onBallMoved} - parentRef={courtRef} - /> - ) - })} + {players.map((player) => ( + onPlayerRemove(player)} + parentRef={courtRef} + availableActions={(pieceRef) => [ + onPlayerRemove(player)} + />, + + bindArrowToPlayer(pieceRef, headRect) + } + />, + player.hasBall && + ]} + /> + ))} {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)}
) } diff --git a/front/components/editor/CourtPlayer.tsx b/front/components/editor/CourtPlayer.tsx index 5f152ed..b3577cf 100644 --- a/front/components/editor/CourtPlayer.tsx +++ b/front/components/editor/CourtPlayer.tsx @@ -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" +import {PlayerPiece} from "./PlayerPiece" +import {Player} from "../../tactic/Player" +import {calculateRatio} from "../../Utils" -export interface PlayerProps { +export interface PlayerProps { player: Player + onDrag: () => void, onChange: (p: Player) => void onRemove: () => void - onBallDrop: (bounds: DOMRect) => void parentRef: RefObject + availableActions: (ro: RefObject) => 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({ player, + onDrag, onChange, onRemove, - onBallDrop, parentRef, -}: PlayerProps) { - const pieceRef = useRef(null) - const ballPiece = useRef(null) - + availableActions, +}: PlayerProps) { const x = player.rightRatio const y = player.bottomRatio const hasBall = player.hasBall + const pieceRef = useRef(null); + return ( { 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) }}>
-
{ - if (e.key == "Delete") onRemove() - }}> -
- {hasBall && ( - - onBallDrop( - ballPiece.current!.getBoundingClientRect(), - ) - } - position={{ x: 0, y: 0 }}> -
- -
-
- )} -
- +
{ + if (e.key == "Delete") onRemove() + }}> +
{availableActions(pieceRef)}
+
diff --git a/front/style/actions/arrow_action.css b/front/style/actions/arrow_action.css new file mode 100644 index 0000000..0588326 --- /dev/null +++ b/front/style/actions/arrow_action.css @@ -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; +} \ No newline at end of file diff --git a/front/style/actions/remove_action.css b/front/style/actions/remove_action.css new file mode 100644 index 0000000..9881b98 --- /dev/null +++ b/front/style/actions/remove_action.css @@ -0,0 +1,14 @@ +.remove-action { + height: 100%; +} + +.remove-action * { + stroke: red; + fill: white; +} + +.remove-action:hover * { + fill: #f1dbdb; + stroke: #ff331a; + cursor: pointer; +} \ No newline at end of file diff --git a/front/style/player.css b/front/style/player.css index 81a6b7e..c5429e7 100644 --- a/front/style/player.css +++ b/front/style/player.css @@ -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 { diff --git a/front/style/theme/default.css b/front/style/theme/default.css index 12c4452..1885746 100644 --- a/front/style/theme/default.css +++ b/front/style/theme/default.css @@ -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; } diff --git a/front/tactic/Action.ts b/front/tactic/Action.ts new file mode 100644 index 0000000..a363ae8 --- /dev/null +++ b/front/tactic/Action.ts @@ -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 +} diff --git a/front/tactic/Player.ts b/front/tactic/Player.ts index a025daf..ccfac74 100644 --- a/front/tactic/Player.ts +++ b/front/tactic/Player.ts @@ -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 } diff --git a/front/tactic/Tactic.ts b/front/tactic/Tactic.ts index 8e06331..296c339 100644 --- a/front/tactic/Tactic.ts +++ b/front/tactic/Tactic.ts @@ -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[] } diff --git a/front/tactic/Team.tsx b/front/tactic/Team.ts similarity index 100% rename from front/tactic/Team.tsx rename to front/tactic/Team.ts diff --git a/front/views/Editor.tsx b/front/views/Editor.tsx index 7e7e8f3..ca06435 100644 --- a/front/views/Editor.tsx +++ b/front/views/Editor.tsx @@ -1,35 +1,26 @@ -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" +import {BasketCourt} from "../components/editor/BasketCourt" import plainCourt from "../assets/court/full_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 { PlayerPiece } from "../components/editor/PlayerPiece" -import { Player } from "../tactic/Player" +import {Rack} from "../components/Rack" +import {PlayerPiece} from "../components/editor/PlayerPiece" +import {Player} from "../tactic/Player" -import { Tactic, TacticContent } from "../tactic/Tactic" -import { fetchAPI } from "../Fetcher" -import { Team } from "../tactic/Team" -import { calculateRatio } from "../Utils" +import {Tactic, TacticContent} from "../tactic/Tactic" +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" +import {CourtObject} from "../tactic/CourtObjects" const ERROR_STYLE: CSSProperties = { borderColor: "red", @@ -166,6 +157,7 @@ function EditorView({ hasBall: false, }, ], + actions: content.actions, } }) } @@ -362,7 +354,7 @@ function EditorView({ }} />
-
+
@@ -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} />
diff --git a/front/views/editor/ActionsRender.tsx b/front/views/editor/ActionsRender.tsx new file mode 100644 index 0000000..23c2ab8 --- /dev/null +++ b/front/views/editor/ActionsRender.tsx @@ -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 ( + + + + ) +} diff --git a/package.json b/package.json index 79c9d46..f3399c3 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/sql/.guard b/sql/.guard new file mode 100644 index 0000000..e69de29 diff --git a/sql/setup-tables.sql b/sql/setup-tables.sql index 0cbe32b..633081f 100644 --- a/sql/setup-tables.sql +++ b/sql/setup-tables.sql @@ -16,10 +16,10 @@ CREATE TABLE Account CREATE TABLE Tactic ( id integer PRIMARY KEY AUTOINCREMENT, - name varchar NOT NULL, - creation_date timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, - owner integer NOT NULL, - content varchar DEFAULT '{"players": [], "objects": []}' NOT NULL, + name varchar NOT NULL, + creation_date timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, + owner integer 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 ); diff --git a/src/App/Controller/EditorController.php b/src/App/Controller/EditorController.php index 8415cdb..5561590 100644 --- a/src/App/Controller/EditorController.php +++ b/src/App/Controller/EditorController.php @@ -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(), ]); }