From 7042b48ffe912cbd7860be204baaf1f369010a01 Mon Sep 17 00:00:00 2001 From: "maxime.batista" Date: Tue, 19 Dec 2023 11:04:04 +0100 Subject: [PATCH 01/15] change court svg --- front/assets/court/court.svg | 63 ----------- front/assets/court/full_court.svg | 136 ++++++++++++++++++++++++ front/assets/court/half_court.svg | 94 ++++++++++++---- front/components/editor/BasketCourt.tsx | 1 - front/views/Editor.tsx | 2 +- front/views/NewTacticPanel.tsx | 2 +- front/views/Visualizer.tsx | 2 +- 7 files changed, 212 insertions(+), 88 deletions(-) delete mode 100644 front/assets/court/court.svg create mode 100644 front/assets/court/full_court.svg diff --git a/front/assets/court/court.svg b/front/assets/court/court.svg deleted file mode 100644 index e01fd58..0000000 --- a/front/assets/court/court.svg +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/front/assets/court/full_court.svg b/front/assets/court/full_court.svg new file mode 100644 index 0000000..cb59a6b --- /dev/null +++ b/front/assets/court/full_court.svg @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/front/assets/court/half_court.svg b/front/assets/court/half_court.svg index 8e7640e..7bf82e0 100644 --- a/front/assets/court/half_court.svg +++ b/front/assets/court/half_court.svg @@ -1,22 +1,74 @@ - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/front/components/editor/BasketCourt.tsx b/front/components/editor/BasketCourt.tsx index 6229afd..c158d89 100644 --- a/front/components/editor/BasketCourt.tsx +++ b/front/components/editor/BasketCourt.tsx @@ -1,4 +1,3 @@ -import CourtSvg from "../../assets/basketball_court.svg?react" import "../../style/basket_court.css" import { RefObject, useRef } from "react" import CourtPlayer from "./CourtPlayer" diff --git a/front/views/Editor.tsx b/front/views/Editor.tsx index 5c10f64..53107bf 100644 --- a/front/views/Editor.tsx +++ b/front/views/Editor.tsx @@ -10,7 +10,7 @@ import "../style/editor.css" import TitleInput from "../components/TitleInput" import { BasketCourt } from "../components/editor/BasketCourt" -import plainCourt from "../assets/court/court.svg" +import plainCourt from "../assets/court/full_court.svg" import halfCourt from "../assets/court/half_court.svg" import { Rack } from "../components/Rack" diff --git a/front/views/NewTacticPanel.tsx b/front/views/NewTacticPanel.tsx index bd9badb..97ec0a5 100644 --- a/front/views/NewTacticPanel.tsx +++ b/front/views/NewTacticPanel.tsx @@ -1,7 +1,7 @@ import "../style/theme/default.css" import "../style/new_tactic_panel.css" -import plainCourt from "../assets/court/court.svg" +import plainCourt from "../assets/court/full_court.svg" import halfCourt from "../assets/court/half_court.svg" import { BASE } from "../Constants" diff --git a/front/views/Visualizer.tsx b/front/views/Visualizer.tsx index 5b09115..701c759 100644 --- a/front/views/Visualizer.tsx +++ b/front/views/Visualizer.tsx @@ -1,6 +1,6 @@ import React, { CSSProperties, useState } from "react" import "../style/visualizer.css" -import Court from "../assets/court/court.svg" +import Court from "../assets/court/full_court.svg" export default function Visualizer({ id, name }: { id: number; name: string }) { const [style, setStyle] = useState({}) From eb744d0da4310daccae2b27ab465edc3e794845f Mon Sep 17 00:00:00 2001 From: Vivien DUFOUR Date: Wed, 20 Dec 2023 08:47:30 +0100 Subject: [PATCH 02/15] Can place ball on the court (#77) Also added player and ball remove by drag and drop them out of the court Co-authored-by: vivien.dufour Co-authored-by: maxime Reviewed-on: https://codefirst.iut.uca.fr/git/IQBall/Application-Web/pulls/77 --- front/components/editor/BallPiece.tsx | 21 +- front/components/editor/BasketCourt.tsx | 33 ++- front/components/editor/CourtBall.tsx | 38 +++ front/components/editor/CourtPlayer.tsx | 26 +- front/style/editor.css | 24 +- front/tactic/{Ball.ts => CourtObjects.ts} | 12 +- front/tactic/Player.ts | 12 +- front/tactic/Tactic.ts | 2 + front/views/Editor.tsx | 297 +++++++++++++++++----- sql/setup-tables.sql | 2 +- src/App/Controller/EditorController.php | 2 +- src/Core/Model/AuthModel.php | 2 +- 12 files changed, 356 insertions(+), 115 deletions(-) create mode 100644 front/components/editor/CourtBall.tsx rename front/tactic/{Ball.ts => CourtObjects.ts} (57%) diff --git a/front/components/editor/BallPiece.tsx b/front/components/editor/BallPiece.tsx index baaba70..b09f811 100644 --- a/front/components/editor/BallPiece.tsx +++ b/front/components/editor/BallPiece.tsx @@ -1,21 +1,8 @@ -import React, { RefObject } from "react" - import "../../style/ball.css" -import Ball from "../../assets/icon/ball.svg?react" -import Draggable from "react-draggable" - -export interface BallPieceProps { - onDrop: () => void - pieceRef: RefObject -} +import BallSvg from "../../assets/icon/ball.svg?react" +import { Ball } from "../../tactic/CourtObjects" -export function BallPiece({ onDrop, pieceRef }: BallPieceProps) { - return ( - -
- -
- - ) +export function BallPiece() { + return } diff --git a/front/components/editor/BasketCourt.tsx b/front/components/editor/BasketCourt.tsx index c158d89..4545f9a 100644 --- a/front/components/editor/BasketCourt.tsx +++ b/front/components/editor/BasketCourt.tsx @@ -1,21 +1,32 @@ import "../../style/basket_court.css" -import { RefObject, useRef } from "react" +import { RefObject } from "react" import CourtPlayer from "./CourtPlayer" + import { Player } from "../../tactic/Player" +import { CourtObject } from "../../tactic/CourtObjects" +import { CourtBall } from "./CourtBall" export interface BasketCourtProps { players: Player[] + objects: CourtObject[] + onPlayerRemove: (p: Player) => void - onBallDrop: (ref: HTMLDivElement) => void onPlayerChange: (p: Player) => void + + onBallRemove: () => void + + onBallMoved: (ball: DOMRect) => void + courtImage: string courtRef: RefObject } export function BasketCourt({ players, + objects, onPlayerRemove, - onBallDrop, + onBallRemove, + onBallMoved, onPlayerChange, courtImage, courtRef, @@ -33,11 +44,25 @@ export function BasketCourt({ player={player} onChange={onPlayerChange} onRemove={() => onPlayerRemove(player)} - onBallDrop={onBallDrop} + onBallDrop={onBallMoved} parentRef={courtRef} /> ) })} + + {objects.map((object) => { + if (object.type == "ball") { + return ( + + ) + } + throw new Error("unknown court object", object.type) + })} ) } diff --git a/front/components/editor/CourtBall.tsx b/front/components/editor/CourtBall.tsx new file mode 100644 index 0000000..9ba5ae5 --- /dev/null +++ b/front/components/editor/CourtBall.tsx @@ -0,0 +1,38 @@ +import React, { useRef } from "react" +import Draggable from "react-draggable" +import { BallPiece } from "./BallPiece" +import { Ball } from "../../tactic/CourtObjects" + +export interface CourtBallProps { + onMoved: (rect: DOMRect) => void + onRemove: () => void + ball: Ball +} + +export function CourtBall({ onMoved, ball, onRemove }: CourtBallProps) { + const pieceRef = useRef(null) + + const x = ball.rightRatio + const y = ball.bottomRatio + + return ( + onMoved(pieceRef.current!.getBoundingClientRect())} + nodeRef={pieceRef}> +
{ + if (e.key == "Delete") onRemove() + }} + style={{ + position: "absolute", + left: `${x * 100}%`, + top: `${y * 100}%`, + }}> + +
+
+ ) +} diff --git a/front/components/editor/CourtPlayer.tsx b/front/components/editor/CourtPlayer.tsx index 6aebdcb..5f152ed 100644 --- a/front/components/editor/CourtPlayer.tsx +++ b/front/components/editor/CourtPlayer.tsx @@ -1,6 +1,5 @@ -import { RefObject, useRef, useState } from "react" +import { RefObject, useRef } from "react" import "../../style/player.css" -import RemoveIcon from "../../assets/icon/remove.svg?react" import { BallPiece } from "./BallPiece" import Draggable from "react-draggable" import { PlayerPiece } from "./PlayerPiece" @@ -11,7 +10,7 @@ export interface PlayerProps { player: Player onChange: (p: Player) => void onRemove: () => void - onBallDrop: (ref: HTMLDivElement) => void + onBallDrop: (bounds: DOMRect) => void parentRef: RefObject } @@ -36,7 +35,6 @@ export default function CourtPlayer({ { const pieceBounds = pieceRef.current!.getBoundingClientRect() @@ -69,15 +67,19 @@ export default function CourtPlayer({ if (e.key == "Delete") onRemove() }}>
- {hasBall && ( - onBallDrop(ballPiece.current!)} - pieceRef={ballPiece} - /> + + onBallDrop( + ballPiece.current!.getBoundingClientRect(), + ) + } + position={{ x: 0, y: 0 }}> +
+ +
+
)}
p.hasBall) == undefined, + const [objects, setObjects] = useState( + isBallOnCourt(content) ? [] : [{ key: "ball" }], ) - const ballPiece = useRef(null) - const courtDivContentRef = useRef(null) - const canDetach = (ref: HTMLDivElement) => { - const refBounds = ref.getBoundingClientRect() + const isBoundsOnCourt = (bounds: DOMRect) => { const courtBounds = courtDivContentRef.current!.getBoundingClientRect() // check if refBounds overlaps courtBounds return !( - refBounds.top > courtBounds.bottom || - refBounds.right < courtBounds.left || - refBounds.bottom < courtBounds.top || - refBounds.left > courtBounds.right + bounds.top > courtBounds.bottom || + bounds.right < courtBounds.left || + bounds.bottom < courtBounds.top || + bounds.left > courtBounds.right ) } @@ -151,6 +154,7 @@ function EditorView({ setContent((content) => { return { + ...content, players: [ ...content.players, { @@ -166,39 +170,185 @@ function EditorView({ }) } - const onBallDrop = (ref: HTMLDivElement) => { - const ballBounds = ref.getBoundingClientRect() - let ballAssigned = false + const onObjectDetach = ( + ref: HTMLDivElement, + rackedObject: RackedCourtObject, + ) => { + const refBounds = ref.getBoundingClientRect() + const courtBounds = courtDivContentRef.current!.getBoundingClientRect() - setContent((content) => { - const players = content.players.map((player) => { - if (ballAssigned) { - return { ...player, hasBall: false } - } - const playerBounds = document - .getElementById(player.id)! - .getBoundingClientRect() - const doesOverlap = !( - ballBounds.top > playerBounds.bottom || - ballBounds.right < playerBounds.left || - ballBounds.bottom < playerBounds.top || - ballBounds.left > playerBounds.right + const { x, y } = calculateRatio(refBounds, courtBounds) + + let courtObject: CourtObject + + switch (rackedObject.key) { + case "ball": + const ballObj = content.objects.findIndex( + (o) => o.type == "ball", + ) + const playerCollidedIdx = getPlayerCollided( + refBounds, + content.players, ) - if (doesOverlap) { - ballAssigned = true + if (playerCollidedIdx != -1) { + onBallDropOnPlayer(playerCollidedIdx) + setContent((content) => { + return { + ...content, + objects: content.objects.toSpliced(ballObj, 1), + } + }) + return + } + + courtObject = { + type: "ball", + rightRatio: x, + bottomRatio: y, + } + break + + default: + throw new Error("unknown court object ", rackedObject.key) + } + + setContent((content) => { + return { + ...content, + objects: [...content.objects, courtObject], + } + }) + } + + const getPlayerCollided = ( + bounds: DOMRect, + players: Player[], + ): number | -1 => { + for (let i = 0; i < players.length; i++) { + const player = players[i] + const playerBounds = document + .getElementById(player.id)! + .getBoundingClientRect() + const doesOverlap = !( + bounds.top > playerBounds.bottom || + bounds.right < playerBounds.left || + bounds.bottom < playerBounds.top || + bounds.left > playerBounds.right + ) + if (doesOverlap) { + return i + } + } + return -1 + } + + const onBallDropOnPlayer = (playerCollidedIdx: number) => { + setContent((content) => { + const ballObj = content.objects.findIndex((o) => o.type == "ball") + let player = content.players.at(playerCollidedIdx) as Player + return { + ...content, + players: content.players.toSpliced(playerCollidedIdx, 1, { + ...player, + hasBall: true, + }), + objects: content.objects.toSpliced(ballObj, 1), + } + }) + } + + const onBallDrop = (refBounds: DOMRect) => { + if (!isBoundsOnCourt(refBounds)) { + removeCourtBall() + return + } + const playerCollidedIdx = getPlayerCollided(refBounds, content.players) + if (playerCollidedIdx != -1) { + setContent((content) => { + return { + ...content, + players: content.players.map((player) => ({ + ...player, + hasBall: false, + })), } - return { ...player, hasBall: doesOverlap } }) - setShowBall(!ballAssigned) - return { players: players } + onBallDropOnPlayer(playerCollidedIdx) + return + } + + if (content.objects.findIndex((o) => o.type == "ball") != -1) { + return + } + + const courtBounds = courtDivContentRef.current!.getBoundingClientRect() + const { x, y } = calculateRatio(refBounds, courtBounds) + let courtObject: CourtObject + + courtObject = { + type: "ball", + rightRatio: x, + bottomRatio: y, + } + + setContent((content) => { + return { + ...content, + players: content.players.map((player) => ({ + ...player, + hasBall: false, + })), + objects: [...content.objects, courtObject], + } }) } + const removePlayer = (player: Player) => { + setContent((content) => ({ + ...content, + players: toSplicedPlayers(content.players, player, false), + objects: [...content.objects], + })) + let setter + switch (player.team) { + case Team.Opponents: + setter = setOpponents + break + case Team.Allies: + setter = setAllies + } + if (player.hasBall) { + setObjects([{ key: "ball" }]) + } + setter((players) => [ + ...players, + { + team: player.team, + pos: player.role, + key: player.role, + }, + ]) + } + + const removeCourtBall = () => { + setContent((content) => { + const ballObj = content.objects.findIndex((o) => o.type == "ball") + return { + ...content, + players: content.players.map((player) => ({ + ...player, + hasBall: false, + })), + objects: content.objects.toSpliced(ballObj, 1), + } + }) + setObjects([{ key: "ball" }]) + } + return (
-
@@ -220,7 +370,9 @@ function EditorView({ id="allies-rack" objects={allies} onChange={setAllies} - canDetach={canDetach} + canDetach={(div) => + isBoundsOnCourt(div.getBoundingClientRect()) + } onElementDetached={onPieceDetach} render={({ team, key }) => ( - {showBall && ( - onBallDrop(ballPiece.current!)} - pieceRef={ballPiece} - /> - )} + + isBoundsOnCourt(div.getBoundingClientRect()) + } + onElementDetached={onObjectDetach} + render={renderCourtObject} + /> + isBoundsOnCourt(div.getBoundingClientRect()) + } onElementDetached={onPieceDetach} render={({ team, key }) => ( { + const playerBounds = document + .getElementById(player.id)! + .getBoundingClientRect() + if (!isBoundsOnCourt(playerBounds)) { + removePlayer(player) + return + } setContent((content) => ({ + ...content, players: toSplicedPlayers( content.players, player, @@ -274,32 +441,10 @@ function EditorView({ })) }} onPlayerRemove={(player) => { - setContent((content) => ({ - players: toSplicedPlayers( - content.players, - player, - false, - ), - })) - let setter - switch (player.team) { - case Team.Opponents: - setter = setOpponents - break - case Team.Allies: - setter = setAllies - } - if (player.hasBall) { - setShowBall(true) - } - setter((players) => [ - ...players, - { - team: player.team, - pos: player.role, - key: player.role, - }, - ]) + removePlayer(player) + }} + onBallRemove={() => { + removeCourtBall() }} />
@@ -309,6 +454,20 @@ function EditorView({ ) } +function isBallOnCourt(content: TacticContent) { + if (content.players.findIndex((p) => p.hasBall) != -1) { + return true + } + return content.objects.findIndex((o) => o.type == "ball") != -1 +} + +function renderCourtObject(courtObject: RackedCourtObject) { + if (courtObject.key == "ball") { + return + } + throw new Error("unknown racked court object ", courtObject.key) +} + function getRackPlayers(team: Team, players: Player[]): RackedPlayer[] { return ["1", "2", "3", "4", "5"] .filter( diff --git a/sql/setup-tables.sql b/sql/setup-tables.sql index efb20c6..0cbe32b 100644 --- a/sql/setup-tables.sql +++ b/sql/setup-tables.sql @@ -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": []}' NOT NULL, + content varchar DEFAULT '{"players": [], "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 3bbbe61..8415cdb 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": []}', + "content" => '{"players": [], "objects": []}', "courtType" => $courtType->name(), ]); } diff --git a/src/Core/Model/AuthModel.php b/src/Core/Model/AuthModel.php index e8710c0..929eb99 100644 --- a/src/Core/Model/AuthModel.php +++ b/src/Core/Model/AuthModel.php @@ -64,7 +64,7 @@ class AuthModel { public function login(string $email, string $password, array &$failures): ?Account { $hash = $this->gateway->getHash($email); if ($hash == null or (!password_verify($password, $hash))) { - $failures[] = new ValidationFail("email","Adresse email ou mot de passe invalide"); + $failures[] = new ValidationFail("email", "Adresse email ou mot de passe invalide"); return null; } return $this->gateway->getAccountFromMail($email); From a9a8865d6b16468a982200ee8c1d54c5c0eb4786 Mon Sep 17 00:00:00 2001 From: "vivien.dufour" Date: Wed, 20 Dec 2023 09:17:06 +0100 Subject: [PATCH 03/15] blocks the ability to scroll up and down in the editor --- front/style/editor.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/front/style/editor.css b/front/style/editor.css index a7e72f9..196b40e 100644 --- a/front/style/editor.css +++ b/front/style/editor.css @@ -7,6 +7,8 @@ background-color: var(--background-color); flex-direction: column; + + overflow: hidden; } #topbar-left { From de8fbdb3a21a88e5266f1ef7f2810203cba5b258 Mon Sep 17 00:00:00 2001 From: Override-6 Date: Fri, 1 Dec 2023 15:02:37 +0100 Subject: [PATCH 04/15] Add arrows to drag and drop on the court --- front/assets/icon/remove.svg | 2 +- front/components/actions/ArrowAction.tsx | 52 ++++++++++++ front/components/actions/BallAction.tsx | 12 +++ front/components/actions/RemoveAction.tsx | 15 ++++ front/components/editor/BallPiece.tsx | 3 +- front/components/editor/BasketCourt.tsx | 99 ++++++++++++++++++----- front/components/editor/CourtPlayer.tsx | 69 ++++++---------- front/style/actions/arrow_action.css | 33 ++++++++ front/style/actions/remove_action.css | 14 ++++ front/style/player.css | 44 +++------- front/style/theme/default.css | 1 + front/tactic/Action.ts | 14 ++++ front/tactic/Player.ts | 8 +- front/tactic/Tactic.ts | 2 + front/tactic/{Team.tsx => Team.ts} | 0 front/views/Editor.tsx | 51 ++++++------ front/views/editor/ActionsRender.tsx | 35 ++++++++ package.json | 5 +- sql/.guard | 0 sql/setup-tables.sql | 8 +- src/App/Controller/EditorController.php | 2 +- 21 files changed, 336 insertions(+), 133 deletions(-) create mode 100644 front/components/actions/ArrowAction.tsx create mode 100644 front/components/actions/BallAction.tsx create mode 100644 front/components/actions/RemoveAction.tsx create mode 100644 front/style/actions/arrow_action.css create mode 100644 front/style/actions/remove_action.css create mode 100644 front/tactic/Action.ts rename front/tactic/{Team.tsx => Team.ts} (100%) create mode 100644 front/views/editor/ActionsRender.tsx create mode 100644 sql/.guard 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(), ]); } From df3e60ca1a6186abbd7a7fe1aedd595ec864f0ba Mon Sep 17 00:00:00 2001 From: Override-6 Date: Thu, 7 Dec 2023 21:35:19 +0100 Subject: [PATCH 05/15] make straight lines --- front/components/actions/ArrowAction.tsx | 65 ++++++++--------- front/components/actions/BallAction.tsx | 12 +++- front/components/arrows/BendableArrow.tsx | 81 +++++++++++++++++++++ front/components/arrows/Pos.ts | 36 ++++++++++ front/components/editor/BasketCourt.tsx | 86 +++++++++++++++-------- front/components/editor/CourtPlayer.tsx | 6 +- front/style/actions/arrow_action.css | 14 ++-- front/views/Editor.tsx | 11 +-- front/views/editor/ActionsRender.tsx | 33 +++------ package.json | 1 + 10 files changed, 242 insertions(+), 103 deletions(-) create mode 100644 front/components/arrows/BendableArrow.tsx create mode 100644 front/components/arrows/Pos.ts diff --git a/front/components/actions/ArrowAction.tsx b/front/components/actions/ArrowAction.tsx index e52670e..286d0aa 100644 --- a/front/components/actions/ArrowAction.tsx +++ b/front/components/actions/ArrowAction.tsx @@ -1,52 +1,47 @@ import "../../style/actions/arrow_action.css" import Draggable from "react-draggable" -import {RefObject, useRef} from "react" -import Xarrow, {useXarrow, Xwrapper} from "react-xarrows" + +import {useRef} from "react" export interface ArrowActionProps { - originRef: RefObject - onArrowDropped: (arrowHead: DOMRect) => void + onHeadDropped: (headBounds: DOMRect) => void + onHeadPicked: (headBounds: DOMRect) => void, + onHeadMoved: (headBounds: DOMRect) => void, } export default function ArrowAction({ - originRef, - onArrowDropped, + onHeadDropped, + onHeadPicked, + onHeadMoved }: ArrowActionProps) { const arrowHeadRef = useRef(null) - const updateXarrow = useXarrow() return (
- - { - const headBounds = - arrowHeadRef.current!.getBoundingClientRect() - updateXarrow() - onArrowDropped(headBounds) - }} - position={{x: 0, y: 0}}> -
- - -
- -
- + { + 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}}> +
+
) } diff --git a/front/components/actions/BallAction.tsx b/front/components/actions/BallAction.tsx index a18659e..5dc8c78 100644 --- a/front/components/actions/BallAction.tsx +++ b/front/components/actions/BallAction.tsx @@ -1,4 +1,6 @@ import {BallPiece} from "../editor/BallPiece"; +import Draggable from "react-draggable"; +import {useRef} from "react"; export interface BallActionProps { @@ -6,7 +8,15 @@ export interface BallActionProps { } export default function BallAction({onDrop}: BallActionProps) { + const ref = useRef(null) return ( - + onDrop(ref.current!)} + nodeRef={ref} + > +
+ +
+
) } \ No newline at end of file diff --git a/front/components/arrows/BendableArrow.tsx b/front/components/arrows/BendableArrow.tsx new file mode 100644 index 0000000..33540fd --- /dev/null +++ b/front/components/arrows/BendableArrow.tsx @@ -0,0 +1,81 @@ +import { CSSProperties, ReactElement, useCallback, useEffect, useRef } from "react" +import { add, Pos, relativeTo, size } from "./Pos" + +export interface BendableArrowProps { + basePos: Pos + startPos: Pos + endPos: Pos + + startRadius?: number + endRadius?: number + + + style?: ArrowStyle +} + +export interface ArrowStyle { + width?: number, + head?: () => ReactElement, + tail?: () => ReactElement, +} + + +const ArrowStyleDefaults = { + width: 4 +} + +export default function BendableArrow({ basePos, startPos, endPos, style, startRadius = 0, endRadius = 0 }: BendableArrowProps) { + const svgRef = useRef(null) + + const pathRef = useRef(null); + + const styleWidth = style?.width ?? ArrowStyleDefaults.width + + + const update = () => { + const startRelative = relativeTo(startPos, basePos) + const endRelative = relativeTo(endPos, basePos) + + // the width and height of the arrow svg + const svgBoxBounds = size(startPos, endPos) + + const left = Math.min(startRelative.x, endRelative.x) + const top = Math.min(startRelative.y, endRelative.y) + + + const svgStyle: CSSProperties = { + width: `${svgBoxBounds.x}px`, + height: `${svgBoxBounds.y}px`, + + left: `${left}px`, + top: `${top}px`, + } + + + const d = `M${startRelative.x - left} ${startRelative.y - top} L${endRelative.x - left} ${endRelative.y - top}` + pathRef.current!.setAttribute("d", d) + + Object.assign(svgRef.current!.style, svgStyle) + } + + useEffect(() => { + //update on resize + window.addEventListener('resize', update) + + return () => window.removeEventListener('resize', update) + }, [svgRef, basePos, startPos, endPos]) + //update on position changes + useEffect(update, [svgRef, basePos, startPos, endPos]) + + return ( + + + + ) +} diff --git a/front/components/arrows/Pos.ts b/front/components/arrows/Pos.ts new file mode 100644 index 0000000..107aee2 --- /dev/null +++ b/front/components/arrows/Pos.ts @@ -0,0 +1,36 @@ +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 } +} + +/** + * Returns x and y distance between given two pos + * @param a + * @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) } +} + +export function add(a: Pos, b: Pos): Pos { + return { x: a.x + b.x, y: a.y + b.y } +} diff --git a/front/components/editor/BasketCourt.tsx b/front/components/editor/BasketCourt.tsx index 89b53e9..8f49c51 100644 --- a/front/components/editor/BasketCourt.tsx +++ b/front/components/editor/BasketCourt.tsx @@ -1,23 +1,27 @@ import "../../style/basket_court.css" -import {ReactElement, RefObject} from "react" + +import {ReactElement, RefObject, 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 BallAction from "../actions/BallAction"; + +import BendableArrow 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"; export interface BasketCourtProps { players: Player[] actions: Action[] objects: CourtObject[] - renderAction: (a: Action) => ReactElement + renderAction: (courtBounds: DOMRect, a: Action, idx: number) => ReactElement setActions: (f: (a: Action[]) => Action[]) => void onPlayerRemove: (p: Player) => void - onBallDrop: (ref: HTMLElement) => void onPlayerChange: (p: Player) => void onBallRemove: () => void @@ -29,25 +33,22 @@ export interface BasketCourtProps { } export function BasketCourt({ - players, - objects, - actions, - renderAction, - setActions, - onBallDrop, - onPlayerRemove, - onBallRemove, - onBallMoved, - onPlayerChange, - courtImage, - courtRef, -}: BasketCourtProps) { - function bindArrowToPlayer( - originRef: RefObject, - arrowHead: DOMRect, - ) { + objects, + onBallMoved, + onBallRemove, + players, + actions, + renderAction, + setActions, + onPlayerRemove, + onPlayerChange, + courtImage, + courtRef, + }: BasketCourtProps) { + + function bindArrowToPlayer(originRef: HTMLElement, arrowHead: DOMRect) { for (const player of players) { - if (player.id == originRef.current!.id) { + if (player.id == originRef.id) { continue } @@ -65,7 +66,7 @@ export function BasketCourt({ ) { const action = { type: MovementActionKind.SCREEN, - moveFrom: originRef.current!.id, + moveFrom: originRef.id, moveTo: player.id, } setActions((actions) => [...actions, action]) @@ -75,9 +76,18 @@ export function BasketCourt({ const updateArrows = useXarrow() + const [previewArrowOriginPos, setPreviewArrowOriginPos] = + useState(NULL_POS) + const [previewArrowEndPos, setPreviewArrowEndPos] = useState(NULL_POS) + const [isPreviewArrowEnabled, setPreviewArrowEnabled] = useState(false) + return ( -
diff --git a/front/style/actions/arrow_action.css b/front/style/actions/arrow_action.css index 0588326..15b2bc5 100644 --- a/front/style/actions/arrow_action.css +++ b/front/style/actions/arrow_action.css @@ -18,16 +18,16 @@ } .arrow-head-xarrow { - visibility: hidden; + visibility: visible; } .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 +/*.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/views/Editor.tsx b/front/views/Editor.tsx index ca06435..11c9fc5 100644 --- a/front/views/Editor.tsx +++ b/front/views/Editor.tsx @@ -18,7 +18,7 @@ import {Team} from "../tactic/Team" import {calculateRatio} from "../Utils" import SavingState, {SaveState, SaveStates,} from "../components/editor/SavingState" -import {renderAction} from "./editor/ActionsRender" +import {ActionRender} from "./editor/ActionsRender" import {CourtObject} from "../tactic/CourtObjects" @@ -417,12 +417,13 @@ function EditorView({ courtRef={courtDivContentRef} actions={content.actions} setActions={(actions) => - setContent((c) => ({ - players: c.players, - actions: actions(c.actions), + setContent((content) => ({ + ...content, + players: content.players, + actions: actions(content.actions), })) } - renderAction={renderAction} + renderAction={(basePos, action, idx) => } onPlayerChange={(player) => { const playerBounds = document .getElementById(player.id)! diff --git a/front/views/editor/ActionsRender.tsx b/front/views/editor/ActionsRender.tsx index 23c2ab8..3e93165 100644 --- a/front/views/editor/ActionsRender.tsx +++ b/front/views/editor/ActionsRender.tsx @@ -1,35 +1,24 @@ 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 renderAction(action: Action) { +export function ActionRender({basePos, action}: {basePos: Pos, 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: - } - - + const fromPos = document.getElementById(from)!.getBoundingClientRect() + const toPos = document.getElementById(to)!.getBoundingClientRect() return ( - - - + ) } diff --git a/package.json b/package.json index f3399c3..9c3a9d8 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ }, "devDependencies": { "@vitejs/plugin-react": "^4.1.0", + "eslint-plugin-react-hooks": "^4.6.0", "prettier": "^3.1.0", "typescript": "^5.2.2", "vite-plugin-svgr": "^4.1.0" From b98fe575b0d20cc8a97f6ab1594a853d9bf91b01 Mon Sep 17 00:00:00 2001 From: Override-6 Date: Sun, 10 Dec 2023 15:41:28 +0100 Subject: [PATCH 06/15] add start/end radius --- front/components/arrows/BendableArrow.tsx | 21 +++++++++++++++++---- front/components/arrows/Pos.ts | 5 +++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/front/components/arrows/BendableArrow.tsx b/front/components/arrows/BendableArrow.tsx index 33540fd..2374251 100644 --- a/front/components/arrows/BendableArrow.tsx +++ b/front/components/arrows/BendableArrow.tsx @@ -1,5 +1,5 @@ import { CSSProperties, ReactElement, useCallback, useEffect, useRef } from "react" -import { add, Pos, relativeTo, size } from "./Pos" +import { add, angle, Pos, relativeTo, size } from "./Pos" export interface BendableArrowProps { basePos: Pos @@ -24,6 +24,15 @@ const ArrowStyleDefaults = { width: 4 } +function constraintInCircle(pos: Pos, from: Pos, radius: number): Pos { + const theta = angle(pos, from) + + return { + x: pos.x - Math.sin(theta) * radius, + y: pos.y - Math.cos(theta) * radius + } +} + export default function BendableArrow({ basePos, startPos, endPos, style, startRadius = 0, endRadius = 0 }: BendableArrowProps) { const svgRef = useRef(null) @@ -36,11 +45,15 @@ export default function BendableArrow({ basePos, startPos, endPos, style, startR const startRelative = relativeTo(startPos, basePos) const endRelative = relativeTo(endPos, basePos) + const tailPos = constraintInCircle(startRelative, endRelative, startRadius) + const headPos = constraintInCircle(endRelative, startRelative, endRadius) + + // the width and height of the arrow svg const svgBoxBounds = size(startPos, endPos) - const left = Math.min(startRelative.x, endRelative.x) - const top = Math.min(startRelative.y, endRelative.y) + const left = Math.min(tailPos.x, headPos.x) + const top = Math.min(tailPos.y, headPos.y) const svgStyle: CSSProperties = { @@ -52,7 +65,7 @@ export default function BendableArrow({ basePos, startPos, endPos, style, startR } - const d = `M${startRelative.x - left} ${startRelative.y - top} L${endRelative.x - left} ${endRelative.y - top}` + const d = `M${tailPos.x - left} ${tailPos.y - top} L${headPos.x - left} ${headPos.y - top}` pathRef.current!.setAttribute("d", d) Object.assign(svgRef.current!.style, svgStyle) diff --git a/front/components/arrows/Pos.ts b/front/components/arrows/Pos.ts index 107aee2..0d3aa9c 100644 --- a/front/components/arrows/Pos.ts +++ b/front/components/arrows/Pos.ts @@ -34,3 +34,8 @@ export function size(a: Pos, b: Pos): Pos { export function add(a: Pos, b: Pos): Pos { 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) +} \ No newline at end of file From 6c9247106240faadd1b02403d982b836513038c8 Mon Sep 17 00:00:00 2001 From: Override-6 Date: Sun, 10 Dec 2023 16:37:01 +0100 Subject: [PATCH 07/15] add head/tails, add control points --- front/components/actions/ArrowAction.tsx | 6 +- front/components/actions/RemoveAction.tsx | 7 +- front/components/arrows/BendableArrow.tsx | 305 +++++++++++++++++++--- front/components/arrows/Pos.ts | 12 +- front/components/editor/BasketCourt.tsx | 69 +++-- front/components/editor/CourtPlayer.tsx | 4 +- front/style/actions/arrow_action.css | 7 +- front/style/actions/remove_action.css | 2 +- front/style/bendable_arrows.css | 20 ++ front/style/colors.css | 13 + front/style/player.css | 5 +- front/tactic/Action.ts | 10 +- front/tactic/Player.ts | 3 +- front/views/Editor.tsx | 42 ++- front/views/editor/ActionsRender.tsx | 24 -- front/views/editor/CourtAction.tsx | 25 ++ src/App/react-display-file.php | 1 + 17 files changed, 429 insertions(+), 126 deletions(-) create mode 100644 front/style/bendable_arrows.css create mode 100644 front/style/colors.css delete mode 100644 front/views/editor/ActionsRender.tsx create mode 100644 front/views/editor/CourtAction.tsx diff --git a/front/components/actions/ArrowAction.tsx b/front/components/actions/ArrowAction.tsx index 286d0aa..54a5527 100644 --- a/front/components/actions/ArrowAction.tsx +++ b/front/components/actions/ArrowAction.tsx @@ -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(null) diff --git a/front/components/actions/RemoveAction.tsx b/front/components/actions/RemoveAction.tsx index 1992453..bea72da 100644 --- a/front/components/actions/RemoveAction.tsx +++ b/front/components/actions/RemoveAction.tsx @@ -6,10 +6,5 @@ export interface RemoveActionProps { } export default function RemoveAction({ onRemove }: RemoveActionProps) { - return ( - - ) + return } diff --git a/front/components/arrows/BendableArrow.tsx b/front/components/arrows/BendableArrow.tsx index 2374251..d37805d 100644 --- a/front/components/arrows/BendableArrow.tsx +++ b/front/components/arrows/BendableArrow.tsx @@ -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 ( + + + + ) +} + +export default function BendableArrow({ + startPos, + + segments, + onSegmentsChanges, + + style, + startRadius = 0, + endRadius = 0, + }: BendableArrowProps) { + + + const containerRef = useRef(null) const svgRef = useRef(null) - const pathRef = useRef(null); + const pathRef = useRef(null) const styleWidth = style?.width ?? ArrowStyleDefaults.width + const [controlPointsDots, setControlPointsDots] = useState( + [], + ) + + useEffect(() => { + setInternalSegments(segments) + }, [segments]); + + const [internalSegments, setInternalSegments] = useState(segments) + + const [isSelected, setIsSelected] = useState(false) + + + const headRef = useRef(null) + const tailRef = useRef(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 ( + { + 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 ( - - - +
+ + + + +
+ {style?.head?.call(style) ?? } +
+ +
+ {style?.tail?.call(style) ?? } +
+ + {isSelected && controlPointsDots} +
+ ) +} + +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(null) + + return ( + { + 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}} + > +
+ ) } diff --git a/front/components/arrows/Pos.ts b/front/components/arrows/Pos.ts index 0d3aa9c..d829015 100644 --- a/front/components/arrows/Pos.ts +++ b/front/components/arrows/Pos.ts @@ -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) -} \ No newline at end of file +} diff --git a/front/components/editor/BasketCourt.tsx b/front/components/editor/BasketCourt.tsx index 8f49c51..822ab62 100644 --- a/front/components/editor/BasketCourt.tsx +++ b/front/components/editor/BasketCourt.tsx @@ -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(NULL_POS) - const [previewArrowEndPos, setPreviewArrowEndPos] = useState(NULL_POS) const [isPreviewArrowEnabled, setPreviewArrowEnabled] = useState(false) + const [previewArrowEdges, setPreviewArrowEdges] = useState([]) + + 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 (
{"court"} - {actions.map((action, idx) => renderAction(courtRef.current!.getBoundingClientRect(), action, idx))} - + {actions.map((action, idx) => renderAction(action, idx))} {players.map((player) => ( updateActionsRelatedTo(player)} onChange={onPlayerChange} onRemove={() => onPlayerRemove(player)} parentRef={courtRef} @@ -104,18 +124,22 @@ export function BasketCourt({ - 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 && onBallMoved(ref.getBoundingClientRect())}/> @@ -139,9 +163,12 @@ export function BasketCourt({ {isPreviewArrowEnabled && ( {}} + endRadius={17} + startRadius={26} /> )}
diff --git a/front/components/editor/CourtPlayer.tsx b/front/components/editor/CourtPlayer.tsx index 9df09b7..785516e 100644 --- a/front/components/editor/CourtPlayer.tsx +++ b/front/components/editor/CourtPlayer.tsx @@ -8,7 +8,7 @@ import {calculateRatio} from "../../Utils" export interface PlayerProps
{ player: Player - onDrag: () => void, + onDrag: () => void onChange: (p: Player) => void onRemove: () => void parentRef: RefObject @@ -30,7 +30,7 @@ export default function CourtPlayer({ const y = player.bottomRatio const hasBall = player.hasBall - const pieceRef = useRef(null); + const pieceRef = useRef(null) return ( 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) => } + renderAction={(action, i) => ( + + 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} />
@@ -478,6 +490,16 @@ function getRackPlayers(team: Team, players: Player[]): RackedPlayer[] { .map((key) => ({ team, key })) } +function debounceAsync(f: (args: A) => Promise, delay = 1000): (args: A) => Promise { + let task = 0; + return (args: A) => { + clearTimeout(task) + return new Promise(resolve => { + task = setTimeout(() => f(args).then(resolve), delay) + }) + } +} + function useContentState( initialContent: S, initialSaveState: SaveState, diff --git a/front/views/editor/ActionsRender.tsx b/front/views/editor/ActionsRender.tsx deleted file mode 100644 index 3e93165..0000000 --- a/front/views/editor/ActionsRender.tsx +++ /dev/null @@ -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 ( - - ) -} diff --git a/front/views/editor/CourtAction.tsx b/front/views/editor/CourtAction.tsx new file mode 100644 index 0000000..90fadab --- /dev/null +++ b/front/views/editor/CourtAction.tsx @@ -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 ( + { + onActionChanges({...action, segments: edges}) + }} + endRadius={26} + startRadius={26} + /> + ) +} diff --git a/src/App/react-display-file.php b/src/App/react-display-file.php index 46b039f..ca672d0 100755 --- a/src/App/react-display-file.php +++ b/src/App/react-display-file.php @@ -30,6 +30,7 @@ height: 100%; width: 100%; margin: 0; + overflow: hidden; } From afd7b0570c8b687c3b5345f13219ec5966fadfb5 Mon Sep 17 00:00:00 2001 From: maxime Date: Sun, 17 Dec 2023 18:19:31 +0100 Subject: [PATCH 08/15] reset arrow curves by deleting the central control point --- front/components/actions/ArrowAction.tsx | 10 +- front/components/actions/BallAction.tsx | 3 +- front/components/arrows/BendableArrow.tsx | 185 ++++++++++++---------- front/components/arrows/Pos.ts | 10 +- front/components/editor/BasketCourt.tsx | 108 ++++++++----- front/components/editor/CourtPlayer.tsx | 29 ++-- front/style/bendable_arrows.css | 9 +- front/tactic/Action.ts | 4 +- front/tactic/Player.ts | 1 - front/views/Editor.tsx | 27 ++-- front/views/editor/CourtAction.tsx | 13 +- 11 files changed, 226 insertions(+), 173 deletions(-) diff --git a/front/components/actions/ArrowAction.tsx b/front/components/actions/ArrowAction.tsx index 54a5527..548d9b9 100644 --- a/front/components/actions/ArrowAction.tsx +++ b/front/components/actions/ArrowAction.tsx @@ -1,7 +1,7 @@ import "../../style/actions/arrow_action.css" import Draggable from "react-draggable" -import {useRef} from "react" +import { useRef } from "react" export interface ArrowActionProps { onHeadDropped: (headBounds: DOMRect) => void @@ -18,7 +18,7 @@ export default function ArrowAction({ return (
-
+
-
+ position={{ x: 0, y: 0 }}> +
) diff --git a/front/components/actions/BallAction.tsx b/front/components/actions/BallAction.tsx index 5dc8c78..e8147c6 100644 --- a/front/components/actions/BallAction.tsx +++ b/front/components/actions/BallAction.tsx @@ -19,4 +19,5 @@ export default function BallAction({onDrop}: BallActionProps) {
) -} \ No newline at end of file +} + diff --git a/front/components/arrows/BendableArrow.tsx b/front/components/arrows/BendableArrow.tsx index d37805d..4e15843 100644 --- a/front/components/arrows/BendableArrow.tsx +++ b/front/components/arrows/BendableArrow.tsx @@ -1,5 +1,12 @@ -import {CSSProperties, ReactElement, useCallback, useEffect, useRef, useState,} from "react" -import {angle, middlePos, Pos, relativeTo} 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" @@ -39,27 +46,24 @@ function constraintInCircle(pos: Pos, from: Pos, radius: number): Pos { } } - -function Triangle({fill}: {fill: string}) { +function Triangle({ fill }: { fill: string }) { return ( - + ) } export default function BendableArrow({ - startPos, - - segments, - onSegmentsChanges, - - style, - startRadius = 0, - endRadius = 0, - }: BendableArrowProps) { + startPos, + segments, + onSegmentsChanges, + style, + startRadius = 0, + endRadius = 0, +}: BendableArrowProps) { const containerRef = useRef(null) const svgRef = useRef(null) @@ -73,47 +77,45 @@ export default function BendableArrow({ useEffect(() => { setInternalSegments(segments) - }, [segments]); + }, [segments]) const [internalSegments, setInternalSegments] = useState(segments) const [isSelected, setIsSelected] = useState(false) - const headRef = useRef(null) const tailRef = useRef(null) useEffect(() => { - - const basePos = containerRef.current!.parentElement!.getBoundingClientRect() + const basePos = + containerRef.current!.parentElement!.getBoundingClientRect() setControlPointsDots(computeControlPoints(basePos)) const selectionHandler = (e: MouseEvent) => { - if (!(e.target instanceof Node)) - return + if (!(e.target instanceof Node)) return - setIsSelected(containerRef.current!.contains(e.target)) + const isSelected = containerRef.current!.contains(e.target) + setIsSelected(isSelected) } - document.addEventListener('mousedown', selectionHandler) - return () => document.removeEventListener('mousedown', selectionHandler) - - }, []); - + document.addEventListener("mousedown", selectionHandler) + return () => document.removeEventListener("mousedown", selectionHandler) + }, []) function computeControlPoints(basePos: Pos) { - return internalSegments.map(({next, controlPoint}, i) => { + 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, - } + const cpPos = controlPoint + ? relativeTo(controlPoint, basePos) + : { + x: prevRelative.x / 2 + nextRelative.x / 2, + y: prevRelative.y / 2 + nextRelative.y / 2, + } return ( { const segment = internalSegments[i] - const segments = internalSegments.toSpliced(i, 1, {...segment, controlPoint}) + const segments = internalSegments.toSpliced(i, 1, { + ...segment, + controlPoint, + }) onSegmentsChanges(segments) }} onMoves={(controlPoint) => { - setInternalSegments(is => { - return is.toSpliced(i, 1, {...is[i], controlPoint}) + setInternalSegments((is) => { + return is.toSpliced(i, 1, { + ...is[i], + controlPoint, + }) }) }} /> @@ -135,9 +143,7 @@ export default function BendableArrow({ }) } - 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 @@ -146,16 +152,18 @@ export default function BendableArrow({ const endPos = segment.next - const basePos = containerRef.current!.parentElement!.getBoundingClientRect() + const basePos = + containerRef.current!.parentElement!.getBoundingClientRect() const startRelative = relativeTo(startPos, basePos) const endRelative = relativeTo(endPos!, basePos) - const controlPoint = segment.controlPoint ? relativeTo(segment.controlPoint, basePos) : { - x: startRelative.x / 2 + endRelative.x / 2, - y: startRelative.y / 2 + endRelative.y / 2, - } - + const controlPoint = segment.controlPoint + ? relativeTo(segment.controlPoint, basePos) + : { + x: startRelative.x / 2 + endRelative.x / 2, + y: startRelative.y / 2 + endRelative.y / 2, + } const tailPos = constraintInCircle( startRelative, @@ -171,53 +179,56 @@ export default function BendableArrow({ 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, controlPoint) * (180 / Math.PI)}deg)` + 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)` + transform: `translateX(-50%) rotate(${ + -angle(headPos, controlPoint) * (180 / Math.PI) + }deg)`, } as CSSProperties) - 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 segmentsRelatives = internalSegments.map( + ({ next, controlPoint }) => { + return { + next: relativeTo(next, basePos), + cp: controlPoint + ? relativeTo(controlPoint, basePos) + : undefined, + } + }, + ) const computedSegments = segmentsRelatives - .map(({next: n, cp}, idx) => { + .map(({ next: n, cp }, idx) => { let next = n if (idx == internalSegments.length - 1) { //if it is the last element - next = constraintInCircle( - next, - controlPoint, - endRadius!, - ) + 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}` + return `C${cp.x - left} ${cp.y - top}, ${cp.x - left} ${ + cp.y - top + }, ${next.x - left} ${next.y - top}` }) .join(" ") @@ -225,16 +236,15 @@ export default function BendableArrow({ pathRef.current!.setAttribute("d", d) Object.assign(svgRef.current!.style, svgStyle) - if (isSelected) { - setControlPointsDots(computeControlPoints(basePos)) - } + setControlPointsDots(computeControlPoints(basePos)) }, [startPos, internalSegments]) - useEffect(update, [update]) return ( -
+
-
- {style?.head?.call(style) ?? } +
+ {style?.head?.call(style) ?? }
-
- {style?.tail?.call(style) ?? } +
+ {style?.tail?.call(style) ?? }
{isSelected && controlPointsDots} @@ -272,18 +282,18 @@ export default function BendableArrow({ interface ControlPointProps { pos: Pos - basePos: Pos, + basePos: Pos onMoves: (currentPos: Pos) => void - onPosValidated: (newPos: Pos) => void, + onPosValidated: (newPos: Pos | undefined) => void radius?: number } function ControlPoint({ - pos, - onMoves, - onPosValidated, - radius = 7, - }: ControlPointProps) { + pos, + onMoves, + onPosValidated, + radius = 7, +}: ControlPointProps) { const ref = useRef(null) return ( @@ -297,8 +307,7 @@ function ControlPoint({ const pointPos = middlePos(ref.current!.getBoundingClientRect()) onMoves(pointPos) }} - position={{x: pos.x - radius, y: pos.y - radius}} - > + position={{ x: pos.x - radius, y: pos.y - radius }}>
{ + if (e.key == "Delete") { + onPosValidated(undefined) + } + }} + tabIndex={0} /> ) diff --git a/front/components/arrows/Pos.ts b/front/components/arrows/Pos.ts index d829015..c3a1863 100644 --- a/front/components/arrows/Pos.ts +++ b/front/components/arrows/Pos.ts @@ -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,11 +28,11 @@ 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 { diff --git a/front/components/editor/BasketCourt.tsx b/front/components/editor/BasketCourt.tsx index 822ab62..4e04ea4 100644 --- a/front/components/editor/BasketCourt.tsx +++ b/front/components/editor/BasketCourt.tsx @@ -3,29 +3,31 @@ import {CourtBall} from "./CourtBall"; import {ReactElement, RefObject, useCallback, useState,} from "react" + import CourtPlayer from "./CourtPlayer" -import {Player} from "../../tactic/Player" -import {Action, MovementActionKind} from "../../tactic/Action" +import { Player } from "../../tactic/Player" +import { Action, MovementActionKind } from "../../tactic/Action" import RemoveAction from "../actions/RemoveAction" import ArrowAction from "../actions/ArrowAction" -import BendableArrow, {Segment} from "../arrows/BendableArrow" -import {middlePos, NULL_POS, Pos} from "../arrows/Pos" -import BallAction from "../actions/BallAction"; +import BendableArrow, { Segment } from "../arrows/BendableArrow" +import { middlePos, NULL_POS, Pos } from "../arrows/Pos" +import BallAction from "../actions/BallAction" import {CourtObject} from "../../tactic/CourtObjects"; export interface BasketCourtProps { players: Player[] actions: Action[] objects: CourtObject[] + renderAction: (a: Action, key: number) => ReactElement setActions: (f: (a: Action[]) => Action[]) => void + onPlayerRemove: (p: Player) => void onPlayerChange: (p: Player) => void onBallRemove: () => void - onBallMoved: (ball: DOMRect) => void courtImage: string @@ -33,20 +35,21 @@ export interface BasketCourtProps { } export function BasketCourt({ - objects, - onBallMoved, - onBallRemove, - players, - actions, - renderAction, - setActions, - onPlayerRemove, - onPlayerChange, - courtImage, - courtRef, - }: BasketCourtProps) { - - function bindArrowToPlayer(originRef: HTMLElement, arrowHead: DOMRect) { + players, + actions, + objects, + renderAction, + setActions, + onPlayerRemove, + onPlayerChange, + + onBallMoved, + onBallRemove, + + courtImage, + courtRef, +}: BasketCourtProps) { + function placeArrow(originRef: HTMLElement, arrowHead: DOMRect) { for (const player of players) { if (player.id == originRef.id) { continue @@ -64,17 +67,28 @@ export function BasketCourt({ playerBounds.left > arrowHead.right ) ) { - const targetPos = document.getElementById(player.id)!.getBoundingClientRect() + const targetPos = document + .getElementById(player.id)! + .getBoundingClientRect() const action: Action = { fromPlayerId: originRef.id, toPlayerId: player.id, type: MovementActionKind.SCREEN, moveFrom: middlePos(originRef.getBoundingClientRect()), - segments: [{next: middlePos(targetPos)}], + segments: [{ next: middlePos(targetPos) }], } setActions((actions) => [...actions, action]) + return } } + + const action: Action = { + fromPlayerId: originRef.id, + type: MovementActionKind.MOVE, + moveFrom: middlePos(originRef.getBoundingClientRect()), + segments: [{ next: middlePos(arrowHead) }], + } + setActions((actions) => [...actions, action]) } const [previewArrowOriginPos, setPreviewArrowOriginPos] = @@ -84,27 +98,38 @@ export function BasketCourt({ const [previewArrowEdges, setPreviewArrowEdges] = useState([]) 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} - } + 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} - } + 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 a + }), + ) }, []) return ( -
- {"court"} +
+ {"court"} {actions.map((action, idx) => renderAction(action, idx))} @@ -125,7 +150,7 @@ export function BasketCourt({ key={2} onHeadMoved={(headPos) => setPreviewArrowEdges([ - {next: middlePos(headPos)}, + { next: middlePos(headPos) }, ]) } onHeadPicked={(headPos) => { @@ -133,12 +158,12 @@ export function BasketCourt({ middlePos(pieceRef.getBoundingClientRect()), ) setPreviewArrowEdges([ - {next: middlePos(headPos)}, + { next: middlePos(headPos) }, ]) setPreviewArrowEnabled(true) }} onHeadDropped={(headRect) => { - bindArrowToPlayer(pieceRef, headRect) + placeArrow(pieceRef, headRect) setPreviewArrowEnabled(false) }} />, @@ -167,6 +192,7 @@ export function BasketCourt({ segments={previewArrowEdges} //do nothing on change, not really possible as it's a preview arrow onSegmentsChanges={() => {}} + //TODO place those values in constants endRadius={17} startRadius={26} /> diff --git a/front/components/editor/CourtPlayer.tsx b/front/components/editor/CourtPlayer.tsx index 785516e..f523448 100644 --- a/front/components/editor/CourtPlayer.tsx +++ b/front/components/editor/CourtPlayer.tsx @@ -1,10 +1,9 @@ - -import {ReactNode, RefObject, useRef} from "react" +import { ReactNode, RefObject, useRef } from "react" import "../../style/player.css" 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 { player: Player @@ -62,12 +61,20 @@ export default function CourtPlayer({ left: `${x * 100}%`, top: `${y * 100}%`, }}> -
{ - if (e.key == "Delete") onRemove() - }}> -
{availableActions(pieceRef.current!)}
- +
{ + if (e.key == "Delete") onRemove() + }}> +
+ {availableActions(pieceRef.current!)} +
+
diff --git a/front/style/bendable_arrows.css b/front/style/bendable_arrows.css index eccfb08..75a1be1 100644 --- a/front/style/bendable_arrows.css +++ b/front/style/bendable_arrows.css @@ -3,18 +3,19 @@ border-radius: 100px; background-color: black; + outline: none; } .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) -} \ No newline at end of file +.arrow-path:hover, +.arrow-path:active { + stroke: var(--selection-color); +} diff --git a/front/tactic/Action.ts b/front/tactic/Action.ts index d1741ed..993499a 100644 --- a/front/tactic/Action.ts +++ b/front/tactic/Action.ts @@ -11,8 +11,8 @@ export enum MovementActionKind { export type Action = { type: MovementActionKind } & MovementAction export interface MovementAction { - fromPlayerId: PlayerId, - toPlayerId: PlayerId, + fromPlayerId: PlayerId + toPlayerId?: PlayerId moveFrom: Pos segments: Segment[] } diff --git a/front/tactic/Player.ts b/front/tactic/Player.ts index 97c2ef5..1d71d8a 100644 --- a/front/tactic/Player.ts +++ b/front/tactic/Player.ts @@ -3,7 +3,6 @@ import { Team } from "./Team" export type PlayerId = string export interface Player { - readonly id: PlayerId /** diff --git a/front/views/Editor.tsx b/front/views/Editor.tsx index cf28f90..5e9d32c 100644 --- a/front/views/Editor.tsx +++ b/front/views/Editor.tsx @@ -108,11 +108,17 @@ function EditorView({ const [content, setContent, saveState] = useContentState( initialContent, isInGuestMode ? SaveStates.Guest : SaveStates.Ok, - useMemo(() => debounceAsync((content) => - onContentChange(content).then((success) => - success ? SaveStates.Ok : SaveStates.Err, - ) - , 250), [onContentChange]) + useMemo( + () => + debounceAsync( + (content) => + onContentChange(content).then((success) => + success ? SaveStates.Ok : SaveStates.Err, + ), + 250, + ), + [onContentChange], + ), ) const [allies, setAllies] = useState( @@ -358,7 +364,7 @@ function EditorView({ }} />
-
+
@@ -490,11 +496,14 @@ function getRackPlayers(team: Team, players: Player[]): RackedPlayer[] { .map((key) => ({ team, key })) } -function debounceAsync(f: (args: A) => Promise, delay = 1000): (args: A) => Promise { - let task = 0; +function debounceAsync( + f: (args: A) => Promise, + delay = 1000, +): (args: A) => Promise { + let task = 0 return (args: A) => { clearTimeout(task) - return new Promise(resolve => { + return new Promise((resolve) => { task = setTimeout(() => f(args).then(resolve), delay) }) } diff --git a/front/views/editor/CourtAction.tsx b/front/views/editor/CourtAction.tsx index 90fadab..b7217ba 100644 --- a/front/views/editor/CourtAction.tsx +++ b/front/views/editor/CourtAction.tsx @@ -1,4 +1,4 @@ -import {Action} from "../../tactic/Action" +import { Action } from "../../tactic/Action" import BendableArrow from "../../components/arrows/BendableArrow" export interface CourtActionProps { @@ -6,19 +6,16 @@ export interface CourtActionProps { onActionChanges: (a: Action) => void } -export function CourtAction({ - action, - onActionChanges, - }: CourtActionProps) { - +export function CourtAction({ action, onActionChanges }: CourtActionProps) { return ( { - onActionChanges({...action, segments: edges}) + onActionChanges({ ...action, segments: edges }) }} - endRadius={26} + //TODO place those magic values in constants + endRadius={action.toPlayerId ? 26 : 17} startRadius={26} /> ) From e1da73f6c5d41ca9f46467db0a3ca9961fd66f9e Mon Sep 17 00:00:00 2001 From: "maxime.batista" Date: Wed, 20 Dec 2023 15:28:35 +0100 Subject: [PATCH 09/15] arrows now works with relative positions ratio from their parent --- front/Utils.ts | 12 -- front/assets/court/full_court.svg | 1 - front/components/actions/ArrowAction.tsx | 8 +- front/components/actions/BallAction.tsx | 17 +-- front/components/arrows/BendableArrow.tsx | 137 ++++++++++++---------- front/components/arrows/Pos.ts | 14 +++ front/components/editor/BasketCourt.tsx | 92 +++++++++++---- front/components/editor/CourtPlayer.tsx | 4 +- front/style/basket_court.css | 20 ---- front/style/editor.css | 27 ++++- front/views/Editor.tsx | 77 ++++++++---- front/views/editor/CourtAction.tsx | 9 +- 12 files changed, 255 insertions(+), 163 deletions(-) delete mode 100644 front/Utils.ts delete mode 100644 front/style/basket_court.css diff --git a/front/Utils.ts b/front/Utils.ts deleted file mode 100644 index 523d813..0000000 --- a/front/Utils.ts +++ /dev/null @@ -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 } -} diff --git a/front/assets/court/full_court.svg b/front/assets/court/full_court.svg index cb59a6b..5bfc0de 100644 --- a/front/assets/court/full_court.svg +++ b/front/assets/court/full_court.svg @@ -1,5 +1,4 @@ - diff --git a/front/components/actions/ArrowAction.tsx b/front/components/actions/ArrowAction.tsx index 548d9b9..0c8058b 100644 --- a/front/components/actions/ArrowAction.tsx +++ b/front/components/actions/ArrowAction.tsx @@ -10,10 +10,10 @@ export interface ArrowActionProps { } export default function ArrowAction({ - onHeadDropped, - onHeadPicked, - onHeadMoved, - }: ArrowActionProps) { + onHeadDropped, + onHeadPicked, + onHeadMoved, +}: ArrowActionProps) { const arrowHeadRef = useRef(null) return ( diff --git a/front/components/actions/BallAction.tsx b/front/components/actions/BallAction.tsx index e8147c6..a26785c 100644 --- a/front/components/actions/BallAction.tsx +++ b/front/components/actions/BallAction.tsx @@ -1,23 +1,18 @@ -import {BallPiece} from "../editor/BallPiece"; -import Draggable from "react-draggable"; -import {useRef} from "react"; - +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) { +export default function BallAction({ onDrop }: BallActionProps) { const ref = useRef(null) return ( - onDrop(ref.current!)} - nodeRef={ref} - > + onDrop(ref.current!)} nodeRef={ref}>
- +
) } - diff --git a/front/components/arrows/BendableArrow.tsx b/front/components/arrows/BendableArrow.tsx index 4e15843..f6d698e 100644 --- a/front/components/arrows/BendableArrow.tsx +++ b/front/components/arrows/BendableArrow.tsx @@ -1,17 +1,26 @@ import { CSSProperties, ReactElement, + RefObject, useCallback, useEffect, useRef, useState, } from "react" -import { angle, middlePos, Pos, relativeTo } from "./Pos" +import { + add, + angle, + middlePos, + Pos, + posWithinBase, + ratioWithinBase, +} from "./Pos" import "../../style/bendable_arrows.css" import Draggable from "react-draggable" export interface BendableArrowProps { + area: RefObject startPos: Pos segments: Segment[] onSegmentsChanges: (edges: Segment[]) => void @@ -55,6 +64,7 @@ function Triangle({ fill }: { fill: string }) { } export default function BendableArrow({ + area, startPos, segments, @@ -66,15 +76,10 @@ export default function BendableArrow({ }: BendableArrowProps) { const containerRef = useRef(null) const svgRef = useRef(null) - const pathRef = useRef(null) const styleWidth = style?.width ?? ArrowStyleDefaults.width - const [controlPointsDots, setControlPointsDots] = useState( - [], - ) - useEffect(() => { setInternalSegments(segments) }, [segments]) @@ -86,49 +91,38 @@ export default function BendableArrow({ const headRef = useRef(null) const tailRef = useRef(null) - useEffect(() => { - const basePos = - containerRef.current!.parentElement!.getBoundingClientRect() - - setControlPointsDots(computeControlPoints(basePos)) - - const selectionHandler = (e: MouseEvent) => { - if (!(e.target instanceof Node)) return - - const isSelected = containerRef.current!.contains(e.target) - setIsSelected(isSelected) - } - - 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, - } + function computeControlPoints(parentBase: DOMRect) { + return segments.map(({ 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( + { + x: prevRelative.x / 2 + nextRelative.x / 2, + y: prevRelative.y / 2 + nextRelative.y / 2, + }, + parentBase, + ), + parentBase, + ) return ( { - const segment = internalSegments[i] - const segments = internalSegments.toSpliced(i, 1, { + const segment = segments[i] + const newSegments = segments.toSpliced(i, 1, { ...segment, controlPoint, }) - onSegmentsChanges(segments) + onSegmentsChanges(newSegments) }} onMoves={(controlPoint) => { setInternalSegments((is) => { @@ -144,6 +138,8 @@ export default function BendableArrow({ } const update = useCallback(() => { + const parentBase = area.current!.getBoundingClientRect() + // only one segment is supported for now, which is the first. // any other segments will be ignored const segment = internalSegments[0] ?? null @@ -152,14 +148,11 @@ export default function BendableArrow({ const endPos = segment.next - const basePos = - containerRef.current!.parentElement!.getBoundingClientRect() - - const startRelative = relativeTo(startPos, basePos) - const endRelative = relativeTo(endPos!, basePos) + const startRelative = posWithinBase(startPos, parentBase) + const endRelative = posWithinBase(endPos, parentBase) const controlPoint = segment.controlPoint - ? relativeTo(segment.controlPoint, basePos) + ? posWithinBase(segment.controlPoint, parentBase) : { x: startRelative.x / 2 + endRelative.x / 2, y: startRelative.y / 2 + endRelative.y / 2, @@ -205,10 +198,13 @@ export default function BendableArrow({ const segmentsRelatives = internalSegments.map( ({ next, controlPoint }) => { return { - next: relativeTo(next, basePos), + next: posWithinBase(next, parentBase), cp: controlPoint - ? relativeTo(controlPoint, basePos) - : undefined, + ? posWithinBase(controlPoint, parentBase) + : { + x: startRelative.x / 2 + endRelative.x / 2, + y: startRelative.y / 2 + endRelative.y / 2, + }, } }, ) @@ -219,11 +215,7 @@ export default function BendableArrow({ 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}` + next = constraintInCircle(next, cp, endRadius!) } return `C${cp.x - left} ${cp.y - top}, ${cp.x - left} ${ @@ -235,12 +227,27 @@ export default function BendableArrow({ const d = `M${tailPos.x - left} ${tailPos.y - top} ` + computedSegments pathRef.current!.setAttribute("d", d) Object.assign(svgRef.current!.style, svgStyle) - - setControlPointsDots(computeControlPoints(basePos)) }, [startPos, internalSegments]) useEffect(update, [update]) + useEffect(() => { + const selectionHandler = (e: MouseEvent) => { + if (!(e.target instanceof Node)) return + + 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]) + return (
}
- {isSelected && controlPointsDots} + {isSelected && + computeControlPoints(area.current!.getBoundingClientRect())}
) } interface ControlPointProps { - pos: Pos - basePos: Pos + posRatio: Pos + parentBase: DOMRect onMoves: (currentPos: Pos) => void onPosValidated: (newPos: Pos | undefined) => void radius?: number } function ControlPoint({ - pos, + posRatio, + parentBase, onMoves, onPosValidated, radius = 7, }: ControlPointProps) { const ref = useRef(null) + const pos = posWithinBase(posRatio, parentBase) + return ( { const pointPos = middlePos(ref.current!.getBoundingClientRect()) - onPosValidated(pointPos) + onPosValidated(ratioWithinBase(pointPos, parentBase)) }} onDrag={() => { const pointPos = middlePos(ref.current!.getBoundingClientRect()) - onMoves(pointPos) + onMoves(ratioWithinBase(pointPos, parentBase)) }} position={{ x: pos.x - radius, y: pos.y - radius }}>
void onBallMoved: (ball: DOMRect) => void - courtImage: string + courtImage: () => ReactElement courtRef: RefObject } @@ -70,12 +72,21 @@ export function BasketCourt({ const targetPos = document .getElementById(player.id)! .getBoundingClientRect() + + const courtBounds = courtRef.current!.getBoundingClientRect() + + const start = ratioWithinBase( + middlePos(originRef.getBoundingClientRect()), + courtBounds, + ) + const end = ratioWithinBase(middlePos(targetPos), courtBounds) + const action: Action = { fromPlayerId: originRef.id, toPlayerId: player.id, type: MovementActionKind.SCREEN, - moveFrom: middlePos(originRef.getBoundingClientRect()), - segments: [{ next: middlePos(targetPos) }], + moveFrom: start, + segments: [{ next: end }], } setActions((actions) => [...actions, action]) return @@ -98,8 +109,11 @@ export function BasketCourt({ const [previewArrowEdges, setPreviewArrowEdges] = useState([]) const updateActionsRelatedTo = useCallback((player: Player) => { - const newPos = middlePos( - document.getElementById(player.id)!.getBoundingClientRect(), + const newPos = ratioWithinBase( + middlePos( + document.getElementById(player.id)!.getBoundingClientRect(), + ), + courtRef.current!.getBoundingClientRect(), ) setActions((actions) => actions.map((a) => { @@ -124,14 +138,18 @@ export function BasketCourt({ ) }, []) + const [internActions, setInternActions] = useState([]) + + useLayoutEffect(() => setInternActions(actions), [actions]) + return (
- {"court"} + {courtImage()} - {actions.map((action, idx) => renderAction(action, idx))} + {internActions.map((action, idx) => renderAction(action, idx))} {players.map((player) => ( , + onHeadMoved={(headPos) => { + const baseBounds = + courtRef.current!.getBoundingClientRect() setPreviewArrowEdges([ - { next: middlePos(headPos) }, + { + next: ratioWithinBase( + middlePos(headPos), + baseBounds, + ), + }, ]) - } + }} onHeadPicked={(headPos) => { + const baseBounds = + courtRef.current!.getBoundingClientRect() + setPreviewArrowOriginPos( - middlePos(pieceRef.getBoundingClientRect()), + ratioWithinBase( + middlePos( + pieceRef.getBoundingClientRect(), + ), + baseBounds, + ), ) setPreviewArrowEdges([ - { next: middlePos(headPos) }, + { + next: ratioWithinBase( + middlePos(headPos), + baseBounds, + ), + }, ]) setPreviewArrowEnabled(true) }} @@ -167,7 +205,14 @@ export function BasketCourt({ setPreviewArrowEnabled(false) }} />, - player.hasBall && onBallMoved(ref.getBoundingClientRect())}/> + player.hasBall && ( + + onBallMoved(ref.getBoundingClientRect()) + } + /> + ), ]} /> ))} @@ -188,6 +233,7 @@ export function BasketCourt({ {isPreviewArrowEnabled && ( { player: Player @@ -41,7 +41,7 @@ export default function CourtPlayer({ const pieceBounds = pieceRef.current!.getBoundingClientRect() const parentBounds = parentRef.current!.getBoundingClientRect() - const { x, y } = calculateRatio(pieceBounds, parentBounds) + const { x, y } = ratioWithinBase(pieceBounds, parentBounds) onChange({ id: player.id, diff --git a/front/style/basket_court.css b/front/style/basket_court.css deleted file mode 100644 index 92a520c..0000000 --- a/front/style/basket_court.css +++ /dev/null @@ -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); -} diff --git a/front/style/editor.css b/front/style/editor.css index 196b40e..a305323 100644 --- a/front/style/editor.css +++ b/front/style/editor.css @@ -82,7 +82,9 @@ #court-div { background-color: var(--background-color); + height: 100%; + width: 100%; display: flex; align-items: center; @@ -90,11 +92,32 @@ align-content: center; } -#court-div-bounds { - padding: 20px 20px 20px 20px; +#court-image-div { + background-color: white; + height: 100%; + width: 100%; +} + +.court-container { + display: flex; + align-content: center; + align-items: center; + justify-content: center; + height: 75%; } +#court-image { + height: 100%; + width: 100%; + user-select: none; + -webkit-user-drag: none; +} + +#court-image * { + stroke: var(--selected-team-secondarycolor); +} + .react-draggable { z-index: 2; } diff --git a/front/views/Editor.tsx b/front/views/Editor.tsx index 5e9d32c..a44a2ad 100644 --- a/front/views/Editor.tsx +++ b/front/views/Editor.tsx @@ -1,25 +1,36 @@ -import {CSSProperties, Dispatch, SetStateAction, useCallback, useMemo, useRef, useState,} from "react" +import { + CSSProperties, + Dispatch, + SetStateAction, + useCallback, + useMemo, + useRef, + useState, +} from "react" import "../style/editor.css" import TitleInput from "../components/TitleInput" -import plainCourt from "../assets/court/full_court.svg" -import halfCourt from "../assets/court/half_court.svg" +import PlainCourt from "../assets/court/full_court.svg?react" +import HalfCourt from "../assets/court/half_court.svg?react" -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 SavingState, {SaveState, SaveStates,} from "../components/editor/SavingState" +import SavingState, { + SaveState, + SaveStates, +} from "../components/editor/SavingState" -import {CourtObject} from "../tactic/CourtObjects" -import {CourtAction} from "./editor/CourtAction" -import {BasketCourt} from "../components/editor/BasketCourt"; +import { CourtObject } from "../tactic/CourtObjects" +import { CourtAction } from "./editor/CourtAction" +import { BasketCourt } from "../components/editor/BasketCourt" +import { ratioWithinBase } from "../components/arrows/Pos" const ERROR_STYLE: CSSProperties = { borderColor: "red", @@ -150,7 +161,7 @@ function EditorView({ const refBounds = ref.getBoundingClientRect() const courtBounds = courtDivContentRef.current!.getBoundingClientRect() - const { x, y } = calculateRatio(refBounds, courtBounds) + const { x, y } = ratioWithinBase(refBounds, courtBounds) setContent((content) => { return { @@ -178,7 +189,7 @@ function EditorView({ const refBounds = ref.getBoundingClientRect() const courtBounds = courtDivContentRef.current!.getBoundingClientRect() - const { x, y } = calculateRatio(refBounds, courtBounds) + const { x, y } = ratioWithinBase(refBounds, courtBounds) let courtObject: CourtObject @@ -283,7 +294,7 @@ function EditorView({ } const courtBounds = courtDivContentRef.current!.getBoundingClientRect() - const { x, y } = calculateRatio(refBounds, courtBounds) + const { x, y } = ratioWithinBase(refBounds, courtBounds) let courtObject: CourtObject courtObject = { @@ -309,7 +320,10 @@ 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), + actions: content.actions.filter( + (a) => + a.toPlayerId !== player.id && a.fromPlayerId !== player.id, + ), })) let setter switch (player.team) { @@ -420,12 +434,10 @@ function EditorView({ } courtRef={courtDivContentRef} - actions={content.actions} setActions={(actions) => setContent((content) => ({ ...content, @@ -437,10 +449,15 @@ function EditorView({ setContent((content) => ({ ...content, - actions: content.actions.toSpliced(i, 1, a), + actions: content.actions.toSpliced( + i, + 1, + a, + ), })) } /> @@ -486,6 +503,18 @@ function renderCourtObject(courtObject: RackedCourtObject) { throw new Error("unknown racked court object ", courtObject.key) } +function Court({ courtType }: { courtType: string }) { + return ( +
+ {courtType == "PLAIN" ? ( + + ) : ( + + )} +
+ ) +} + function getRackPlayers(team: Team, players: Player[]): RackedPlayer[] { return ["1", "2", "3", "4", "5"] .filter( diff --git a/front/views/editor/CourtAction.tsx b/front/views/editor/CourtAction.tsx index b7217ba..47dadad 100644 --- a/front/views/editor/CourtAction.tsx +++ b/front/views/editor/CourtAction.tsx @@ -1,14 +1,21 @@ import { Action } from "../../tactic/Action" import BendableArrow from "../../components/arrows/BendableArrow" +import { RefObject } from "react" export interface CourtActionProps { action: Action onActionChanges: (a: Action) => void + courtRef: RefObject } -export function CourtAction({ action, onActionChanges }: CourtActionProps) { +export function CourtAction({ + action, + onActionChanges, + courtRef, +}: CourtActionProps) { return ( { From df52d758aea164606e349b052f850aa4a6c120a4 Mon Sep 17 00:00:00 2001 From: maxime Date: Thu, 21 Dec 2023 01:36:30 +0100 Subject: [PATCH 10/15] make arrows removeable --- front/components/TitleInput.tsx | 2 +- front/components/arrows/BendableArrow.tsx | 9 ++++++++- front/style/bendable_arrows.css | 1 + front/views/Editor.tsx | 12 +++++++++++- front/views/editor/CourtAction.tsx | 3 +++ src/App/react-display-file.php | 1 + 6 files changed, 25 insertions(+), 3 deletions(-) diff --git a/front/components/TitleInput.tsx b/front/components/TitleInput.tsx index 8da1c65..477e3d0 100644 --- a/front/components/TitleInput.tsx +++ b/front/components/TitleInput.tsx @@ -24,7 +24,7 @@ export default function TitleInput({ value={value} onChange={(event) => setValue(event.target.value)} onBlur={(_) => on_validated(value)} - onKeyDown={(event) => { + onKeyUp={(event) => { if (event.key == "Enter") ref.current?.blur() }} /> diff --git a/front/components/arrows/BendableArrow.tsx b/front/components/arrows/BendableArrow.tsx index f6d698e..4d42433 100644 --- a/front/components/arrows/BendableArrow.tsx +++ b/front/components/arrows/BendableArrow.tsx @@ -28,6 +28,8 @@ export interface BendableArrowProps { startRadius?: number endRadius?: number + onDeleteRequested?: () => void + style?: ArrowStyle } @@ -73,6 +75,7 @@ export default function BendableArrow({ style, startRadius = 0, endRadius = 0, + onDeleteRequested = () => {}, }: BendableArrowProps) { const containerRef = useRef(null) const svgRef = useRef(null) @@ -265,6 +268,10 @@ export default function BendableArrow({ stroke={"#000"} strokeWidth={styleWidth} fill="none" + tabIndex={0} + onKeyUp={(e) => { + if (e.key == "Delete") onDeleteRequested() + }} /> @@ -327,7 +334,7 @@ function ControlPoint({ width: radius * 2, height: radius * 2, }} - onKeyDown={(e) => { + onKeyUp={(e) => { if (e.key == "Delete") { onPosValidated(undefined) } diff --git a/front/style/bendable_arrows.css b/front/style/bendable_arrows.css index 75a1be1..ad9fb83 100644 --- a/front/style/bendable_arrows.css +++ b/front/style/bendable_arrows.css @@ -13,6 +13,7 @@ .arrow-path { pointer-events: stroke; cursor: pointer; + outline: none; } .arrow-path:hover, diff --git a/front/views/Editor.tsx b/front/views/Editor.tsx index a44a2ad..610a3ab 100644 --- a/front/views/Editor.tsx +++ b/front/views/Editor.tsx @@ -169,7 +169,7 @@ function EditorView({ players: [ ...content.players, { - id: "player-" + content.players.length, + id: "player-" + element.key + "-" + element.team, team: element.team, role: element.key, rightRatio: x, @@ -450,6 +450,16 @@ function EditorView({ key={i} action={action} courtRef={courtDivContentRef} + onActionDeleted={() => { + setContent((content) => ({ + ...content, + players: content.players, + actions: content.actions.toSpliced( + i, + 1, + ), + })) + }} onActionChanges={(a) => setContent((content) => ({ ...content, diff --git a/front/views/editor/CourtAction.tsx b/front/views/editor/CourtAction.tsx index 47dadad..7a2e037 100644 --- a/front/views/editor/CourtAction.tsx +++ b/front/views/editor/CourtAction.tsx @@ -5,12 +5,14 @@ import { RefObject } from "react" export interface CourtActionProps { action: Action onActionChanges: (a: Action) => void + onActionDeleted: () => void courtRef: RefObject } export function CourtAction({ action, onActionChanges, + onActionDeleted, courtRef, }: CourtActionProps) { return ( @@ -24,6 +26,7 @@ export function CourtAction({ //TODO place those magic values in constants endRadius={action.toPlayerId ? 26 : 17} startRadius={26} + onDeleteRequested={onActionDeleted} /> ) } diff --git a/src/App/react-display-file.php b/src/App/react-display-file.php index ca672d0..7b09417 100755 --- a/src/App/react-display-file.php +++ b/src/App/react-display-file.php @@ -17,6 +17,7 @@ "> + From 727ab33644bcd55bfd9680cd9607afd195bd5cf3 Mon Sep 17 00:00:00 2001 From: "maxime.batista" Date: Fri, 22 Dec 2023 22:22:56 +0100 Subject: [PATCH 11/15] add and remove multiple control points per arrows --- front/components/arrows/BendableArrow.tsx | 303 ++++++++++++++++------ front/components/arrows/Box.ts | 35 +++ front/components/arrows/Pos.ts | 35 ++- front/style/bendable_arrows.css | 4 +- front/views/Editor.tsx | 1 - 5 files changed, 282 insertions(+), 96 deletions(-) create mode 100644 front/components/arrows/Box.ts diff --git a/front/components/arrows/BendableArrow.tsx b/front/components/arrows/BendableArrow.tsx index 4d42433..82c48c1 100644 --- a/front/components/arrows/BendableArrow.tsx +++ b/front/components/arrows/BendableArrow.tsx @@ -1,19 +1,16 @@ -import { - CSSProperties, - ReactElement, - RefObject, - useCallback, - useEffect, - useRef, - useState, -} from "react" +import {CSSProperties, ReactElement, RefObject, useCallback, useEffect, useRef, useState,} from "react" import { add, angle, + between, + distance, middlePos, + minus, + mul, Pos, posWithinBase, ratioWithinBase, + relativeTo, } from "./Pos" import "../../style/bendable_arrows.css" @@ -57,26 +54,27 @@ function constraintInCircle(pos: Pos, from: Pos, radius: number): Pos { } } -function Triangle({ fill }: { fill: string }) { +function Triangle({fill}: { fill: string }) { return ( - + ) } export default function BendableArrow({ - area, - startPos, - - segments, - onSegmentsChanges, - - style, - startRadius = 0, - endRadius = 0, - onDeleteRequested = () => {}, -}: BendableArrowProps) { + area, + startPos, + + segments, + onSegmentsChanges, + + style, + startRadius = 0, + endRadius = 0, + onDeleteRequested = () => { + }, + }: BendableArrowProps) { const containerRef = useRef(null) const svgRef = useRef(null) const pathRef = useRef(null) @@ -95,7 +93,7 @@ export default function BendableArrow({ const tailRef = useRef(null) function computeControlPoints(parentBase: DOMRect) { - return segments.map(({ next, controlPoint }, i) => { + return segments.flatMap(({next, controlPoint}, i) => { const prev = i == 0 ? startPos : segments[i - 1].next const prevRelative = posWithinBase(prev, parentBase) @@ -104,71 +102,99 @@ export default function BendableArrow({ const cpPos = controlPoint || ratioWithinBase( - add( - { - x: prevRelative.x / 2 + nextRelative.x / 2, - y: prevRelative.y / 2 + nextRelative.y / 2, - }, - parentBase, - ), + add(between(prevRelative, nextRelative), parentBase), parentBase, ) - return ( - { + const segment = segments[i] + const newSegments = segments.toSpliced(i, 1, { + ...segment, + controlPoint: newPos, + }) + onSegmentsChanges(newSegments) + } + + return [ + // curve control point + { - const segment = segments[i] + onPosValidated={setControlPointPos} + onRemove={() => setControlPointPos(undefined)} + 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 && { + const currentSegment = segments[i] const newSegments = segments.toSpliced(i, 1, { - ...segment, - controlPoint, + ...currentSegment, + next, }) onSegmentsChanges(newSegments) }} - onMoves={(controlPoint) => { + onRemove={() => { + onSegmentsChanges(segments.toSpliced( + Math.max(i - 1, 0), + 1, + ) + ) + }} + onMoves={next => { setInternalSegments((is) => { return is.toSpliced(i, 1, { ...is[i], - controlPoint, + next, }) }) }} /> - ) + ] }) } const update = useCallback(() => { const parentBase = area.current!.getBoundingClientRect() - // only one segment is supported for now, which is the first. - // any other segments will be ignored - const segment = internalSegments[0] ?? null + const firstSegment = internalSegments[0] ?? null + if (firstSegment == null) throw new Error("segments might not be empty.") - if (segment == null) throw new Error("segments might not be empty.") - - const endPos = segment.next + const lastSegment = internalSegments[internalSegments.length - 1] const startRelative = posWithinBase(startPos, parentBase) - const endRelative = posWithinBase(endPos, parentBase) + const endRelative = posWithinBase(lastSegment.next, parentBase) + + const startNext = firstSegment.controlPoint + ? posWithinBase(firstSegment.controlPoint, parentBase) + : posWithinBase(firstSegment.next, parentBase) - const controlPoint = segment.controlPoint - ? posWithinBase(segment.controlPoint, parentBase) - : { - x: startRelative.x / 2 + endRelative.x / 2, - y: startRelative.y / 2 + endRelative.y / 2, - } + const endPrevious = lastSegment.controlPoint + ? posWithinBase(lastSegment.controlPoint, parentBase) + : internalSegments[internalSegments.length - 2] + ? posWithinBase(internalSegments[internalSegments.length - 2].next, parentBase) + : startRelative const tailPos = constraintInCircle( startRelative, - controlPoint, + startNext, startRadius!, ) const headPos = constraintInCircle( endRelative, - controlPoint, + endPrevious, endRadius!, ) @@ -180,7 +206,7 @@ export default function BendableArrow({ top: tailPos.y + "px", transformOrigin: "top center", transform: `translateX(-50%) rotate(${ - -angle(tailPos, controlPoint) * (180 / Math.PI) + -angle(tailPos, startNext) * (180 / Math.PI) }deg)`, } as CSSProperties) @@ -189,7 +215,7 @@ export default function BendableArrow({ top: headPos.y + "px", transformOrigin: "top center", transform: `translateX(-50%) rotate(${ - -angle(headPos, controlPoint) * (180 / Math.PI) + -angle(headPos, endPrevious) * (180 / Math.PI) }deg)`, } as CSSProperties) @@ -199,21 +225,24 @@ export default function BendableArrow({ } const segmentsRelatives = internalSegments.map( - ({ next, controlPoint }) => { + ({next, controlPoint}, idx) => { + const nextPos = posWithinBase(next, parentBase) return { - next: posWithinBase(next, parentBase), + next: nextPos, cp: controlPoint ? posWithinBase(controlPoint, parentBase) - : { - x: startRelative.x / 2 + endRelative.x / 2, - y: startRelative.y / 2 + endRelative.y / 2, - }, + : between( + idx == 0 + ? startRelative + : posWithinBase(internalSegments[idx - 1].next, parentBase), + nextPos + ), } }, ) const computedSegments = segmentsRelatives - .map(({ next: n, cp }, idx) => { + .map(({next: n, cp}, idx) => { let next = n if (idx == internalSegments.length - 1) { @@ -251,10 +280,68 @@ export default function BendableArrow({ } }, [update, containerRef]) + useEffect(() => { + + const addSegment = (e: MouseEvent) => { + const parentBase = area.current!.getBoundingClientRect() + + const clickAbsolutePos: Pos = {x: e.x, y: e.y} + const clickPosBaseRatio = ratioWithinBase(clickAbsolutePos, parentBase) + + let segmentInsertionIndex = -1 + let segmentInsertionIsOnRightOfCP = false + for (let i = 0; i < segments.length; i++) { + const segment = segments[i] + + let currentPos = i == 0 ? startPos : segments[i - 1].next + let nextPos = segment.next + let controlPointPos = segment.controlPoint ? segment.controlPoint : between(currentPos, nextPos) + + const result = searchOnSegment(currentPos, controlPointPos, 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] + + let newSegments: Segment[] + if (segmentInsertionIsOnRightOfCP) { + newSegments = segments.toSpliced( + segmentInsertionIndex, + 1, + {next: clickPosBaseRatio, controlPoint: splicedSegment.controlPoint}, + {next: splicedSegment.next, controlPoint: undefined} + ) + } else { + newSegments = segments.toSpliced( + segmentInsertionIndex, + 1, + {next: clickPosBaseRatio, controlPoint: undefined}, + {next: splicedSegment.next, controlPoint: splicedSegment.controlPoint} + ) + } + + onSegmentsChanges(newSegments) + } + + pathRef?.current?.addEventListener('dblclick', addSegment) + + return () => { + pathRef?.current?.removeEventListener('dblclick', addSegment) + } + }, [pathRef, segments]); + return (
+ style={{position: "absolute", top: 0, left: 0}}> - {style?.head?.call(style) ?? } + {style?.head?.call(style) ?? }
- {style?.tail?.call(style) ?? } + {style?.tail?.call(style) ?? }
- {isSelected && - computeControlPoints(area.current!.getBoundingClientRect())} + {isSelected && computeControlPoints(area.current!.getBoundingClientRect())}
) } interface ControlPointProps { + className: string posRatio: Pos parentBase: DOMRect onMoves: (currentPos: Pos) => void - onPosValidated: (newPos: Pos | undefined) => void + onPosValidated: (newPos: Pos) => void + onRemove: () => void radius?: number } -function ControlPoint({ - posRatio, - parentBase, - onMoves, - onPosValidated, - radius = 7, -}: ControlPointProps) { +enum PointSegmentSearchResult { + LEFT_TO_CONTROL_POINT, + RIGHT_TO_CONTROL_POINT, + NOT_FOUND +} + +function searchOnSegment(startPos: Pos, controlPoint: Pos, endPos: Pos, point: Pos, minDistance: number): PointSegmentSearchResult { + + + const step = 1 / ((distance(startPos, controlPoint) + distance(controlPoint, endPos)) / minDistance) + + const p0MinusP1 = minus(startPos, controlPoint) + const p2MinusP1 = minus(endPos, controlPoint) + + function getDistanceAt(t: number): number { + // apply the bezier function + const pos = add( + add( + controlPoint, + mul( + p0MinusP1, + (1 - t) ** 2 + ) + ), + mul( + p2MinusP1, + t ** 2 + ) + ) + return distance(pos, point) + } + + + for (let t = 0; t < 1; t += step) { + if (getDistanceAt(t) <= minDistance) + return t >= 0.5 + ? PointSegmentSearchResult.RIGHT_TO_CONTROL_POINT + : PointSegmentSearchResult.LEFT_TO_CONTROL_POINT + } + + return PointSegmentSearchResult.NOT_FOUND +} + +let t = 0 +let slice = 0.5 +for (let i = 0; i < 100; i++) { + t += slice + slice /= 2 +// console.log(t) +} + +function ArrowPoint({ + className, + posRatio, + parentBase, + onMoves, + onPosValidated, + onRemove, + radius = 7, + }: ControlPointProps) { const ref = useRef(null) const pos = posWithinBase(posRatio, parentBase) @@ -325,10 +466,10 @@ function ControlPoint({ const pointPos = middlePos(ref.current!.getBoundingClientRect()) onMoves(ratioWithinBase(pointPos, parentBase)) }} - position={{ x: pos.x - radius, y: pos.y - radius }}> + position={{x: pos.x - radius, y: pos.y - radius}}>
{ if (e.key == "Delete") { - onPosValidated(undefined) + onRemove() } }} tabIndex={0} diff --git a/front/components/arrows/Box.ts b/front/components/arrows/Box.ts new file mode 100644 index 0000000..6fd7548 --- /dev/null +++ b/front/components/arrows/Box.ts @@ -0,0 +1,35 @@ +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) +} \ No newline at end of file diff --git a/front/components/arrows/Pos.ts b/front/components/arrows/Pos.ts index bb59627..08ddc7a 100644 --- a/front/components/arrows/Pos.ts +++ b/front/components/arrows/Pos.ts @@ -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,20 +19,24 @@ 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} } -/** - * Returns x and y distance between given two pos - * @param a - * @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) } -} 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 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 angle(a: Pos, b: Pos): number { @@ -53,3 +57,10 @@ export function posWithinBase(ratio: Pos, base: DOMRect): Pos { y: ratio.y * base.height, } } + +export function between(a: Pos, b: Pos): Pos { + return { + x: a.x / 2 + b.x / 2, + y: a.y / 2 + b.y / 2 + } +} \ No newline at end of file diff --git a/front/style/bendable_arrows.css b/front/style/bendable_arrows.css index ad9fb83..65325f8 100644 --- a/front/style/bendable_arrows.css +++ b/front/style/bendable_arrows.css @@ -1,4 +1,4 @@ -.arrow-edge-control-point { +.arrow-point { cursor: pointer; border-radius: 100px; @@ -6,7 +6,7 @@ outline: none; } -.arrow-edge-control-point:hover { +.arrow-point:hover { background-color: var(--selection-color); } diff --git a/front/views/Editor.tsx b/front/views/Editor.tsx index 610a3ab..7bd1a4c 100644 --- a/front/views/Editor.tsx +++ b/front/views/Editor.tsx @@ -453,7 +453,6 @@ function EditorView({ onActionDeleted={() => { setContent((content) => ({ ...content, - players: content.players, actions: content.actions.toSpliced( i, 1, From 26e99a3a03b596c5252af1db35d98016efcfd14e Mon Sep 17 00:00:00 2001 From: "maxime.batista" Date: Sun, 24 Dec 2023 17:08:26 +0100 Subject: [PATCH 12/15] add screen, move, dribble and throw arrows --- front/assets/icon/arrow.svg | 1 + front/components/actions/ArrowAction.tsx | 33 ++- front/components/actions/RemoveAction.tsx | 10 - front/components/arrows/BendableArrow.tsx | 304 ++++++++++++---------- front/components/arrows/Box.ts | 31 ++- front/components/arrows/Pos.ts | 17 +- front/components/editor/BasketCourt.tsx | 147 ++++++----- front/style/actions/arrow_action.css | 35 +-- front/style/player.css | 6 +- front/tactic/Action.ts | 5 +- front/views/Editor.tsx | 48 +++- front/views/editor/CourtAction.tsx | 36 ++- 12 files changed, 396 insertions(+), 277 deletions(-) create mode 100644 front/assets/icon/arrow.svg delete mode 100644 front/components/actions/RemoveAction.tsx diff --git a/front/assets/icon/arrow.svg b/front/assets/icon/arrow.svg new file mode 100644 index 0000000..87d213c --- /dev/null +++ b/front/assets/icon/arrow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/front/components/actions/ArrowAction.tsx b/front/components/actions/ArrowAction.tsx index 0c8058b..f1a2cc9 100644 --- a/front/components/actions/ArrowAction.tsx +++ b/front/components/actions/ArrowAction.tsx @@ -1,6 +1,6 @@ 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 { @@ -18,7 +18,7 @@ export default function ArrowAction({ return (
-
+ add arrow ) } + +export function ScreenHead() { + return ( +
+ ) +} + +export function MoveToHead() { + return ( + + + + ) +} + +export function ShootHead() { + return ( + + + + ) +} diff --git a/front/components/actions/RemoveAction.tsx b/front/components/actions/RemoveAction.tsx deleted file mode 100644 index bea72da..0000000 --- a/front/components/actions/RemoveAction.tsx +++ /dev/null @@ -1,10 +0,0 @@ -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/arrows/BendableArrow.tsx b/front/components/arrows/BendableArrow.tsx index 82c48c1..7855a10 100644 --- a/front/components/arrows/BendableArrow.tsx +++ b/front/components/arrows/BendableArrow.tsx @@ -1,4 +1,12 @@ -import {CSSProperties, ReactElement, RefObject, useCallback, useEffect, useRef, useState,} from "react" +import { + CSSProperties, + ReactElement, + RefObject, + useCallback, + useEffect, + useRef, + useState, +} from "react" import { add, angle, @@ -10,7 +18,6 @@ import { Pos, posWithinBase, ratioWithinBase, - relativeTo, } from "./Pos" import "../../style/bendable_arrows.css" @@ -21,6 +28,7 @@ export interface BendableArrowProps { startPos: Pos segments: Segment[] onSegmentsChanges: (edges: Segment[]) => void + forceStraight: boolean startRadius?: number endRadius?: number @@ -32,6 +40,7 @@ export interface BendableArrowProps { export interface ArrowStyle { width?: number + dashArray?: string head?: () => ReactElement tail?: () => ReactElement } @@ -54,27 +63,19 @@ function constraintInCircle(pos: Pos, from: Pos, radius: number): Pos { } } -function Triangle({fill}: { fill: string }) { - return ( - - - - ) -} - export default function BendableArrow({ - area, - startPos, - - segments, - onSegmentsChanges, - - style, - startRadius = 0, - endRadius = 0, - onDeleteRequested = () => { - }, - }: BendableArrowProps) { + area, + startPos, + + segments, + onSegmentsChanges, + forceStraight, + + style, + startRadius = 0, + endRadius = 0, + onDeleteRequested, +}: BendableArrowProps) { const containerRef = useRef(null) const svgRef = useRef(null) const pathRef = useRef(null) @@ -93,7 +94,7 @@ export default function BendableArrow({ const tailRef = useRef(null) function computeControlPoints(parentBase: DOMRect) { - return segments.flatMap(({next, controlPoint}, i) => { + return segments.flatMap(({ next, controlPoint }, i) => { const prev = i == 0 ? startPos : segments[i - 1].next const prevRelative = posWithinBase(prev, parentBase) @@ -134,34 +135,34 @@ export default function BendableArrow({ }} />, //next pos point (only if this is not the last segment) - i != segments.length - 1 && { - 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={next => { - setInternalSegments((is) => { - return is.toSpliced(i, 1, { - ...is[i], + i != segments.length - 1 && ( + { + 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={(next) => { + setInternalSegments((is) => { + return is.toSpliced(i, 1, { + ...is[i], + next, + }) + }) + }} + /> + ), ] }) } @@ -170,21 +171,28 @@ export default function BendableArrow({ const parentBase = area.current!.getBoundingClientRect() const firstSegment = internalSegments[0] ?? null - if (firstSegment == null) throw new Error("segments might not be empty.") + if (firstSegment == null) + throw new Error("segments might not be empty.") const lastSegment = internalSegments[internalSegments.length - 1] const startRelative = posWithinBase(startPos, parentBase) const endRelative = posWithinBase(lastSegment.next, parentBase) - const startNext = firstSegment.controlPoint - ? posWithinBase(firstSegment.controlPoint, parentBase) - : posWithinBase(firstSegment.next, parentBase) - - const endPrevious = lastSegment.controlPoint - ? posWithinBase(lastSegment.controlPoint, parentBase) - : internalSegments[internalSegments.length - 2] - ? posWithinBase(internalSegments[internalSegments.length - 2].next, parentBase) + const startNext = + firstSegment.controlPoint && !forceStraight + ? posWithinBase(firstSegment.controlPoint, parentBase) + : posWithinBase(firstSegment.next, parentBase) + + const endPrevious = forceStraight + ? startRelative + : lastSegment.controlPoint + ? posWithinBase(lastSegment.controlPoint, parentBase) + : internalSegments[internalSegments.length - 2] + ? posWithinBase( + internalSegments[internalSegments.length - 2].next, + parentBase, + ) : startRelative const tailPos = constraintInCircle( @@ -192,11 +200,7 @@ export default function BendableArrow({ startNext, startRadius!, ) - const headPos = constraintInCircle( - endRelative, - endPrevious, - endRadius!, - ) + const headPos = constraintInCircle(endRelative, endPrevious, endRadius!) const left = Math.min(tailPos.x, headPos.x) const top = Math.min(tailPos.y, headPos.y) @@ -224,25 +228,29 @@ export default function BendableArrow({ top: top + "px", } - const segmentsRelatives = internalSegments.map( - ({next, controlPoint}, idx) => { - const nextPos = posWithinBase(next, parentBase) - return { - next: nextPos, - cp: controlPoint + const segmentsRelatives = ( + forceStraight ? internalSegments.slice(-1) : internalSegments + ).map(({ next, controlPoint }, idx) => { + const nextPos = posWithinBase(next, parentBase) + return { + next: nextPos, + cp: + controlPoint && !forceStraight ? posWithinBase(controlPoint, parentBase) : between( - idx == 0 - ? startRelative - : posWithinBase(internalSegments[idx - 1].next, parentBase), - nextPos - ), - } - }, - ) + idx == 0 + ? startRelative + : posWithinBase( + internalSegments[idx - 1].next, + parentBase, + ), + nextPos, + ), + } + }) const computedSegments = segmentsRelatives - .map(({next: n, cp}, idx) => { + .map(({ next: n, cp }, idx) => { let next = n if (idx == internalSegments.length - 1) { @@ -259,7 +267,7 @@ export default function BendableArrow({ const d = `M${tailPos.x - left} ${tailPos.y - top} ` + computedSegments pathRef.current!.setAttribute("d", d) Object.assign(svgRef.current!.style, svgStyle) - }, [startPos, internalSegments]) + }, [startPos, internalSegments, forceStraight]) useEffect(update, [update]) @@ -281,12 +289,16 @@ export default function BendableArrow({ }, [update, containerRef]) useEffect(() => { + if (forceStraight) return const addSegment = (e: MouseEvent) => { const parentBase = area.current!.getBoundingClientRect() - const clickAbsolutePos: Pos = {x: e.x, y: e.y} - const clickPosBaseRatio = ratioWithinBase(clickAbsolutePos, parentBase) + const clickAbsolutePos: Pos = { x: e.x, y: e.y } + const clickPosBaseRatio = ratioWithinBase( + clickAbsolutePos, + parentBase, + ) let segmentInsertionIndex = -1 let segmentInsertionIsOnRightOfCP = false @@ -295,53 +307,60 @@ export default function BendableArrow({ let currentPos = i == 0 ? startPos : segments[i - 1].next let nextPos = segment.next - let controlPointPos = segment.controlPoint ? segment.controlPoint : between(currentPos, nextPos) - - const result = searchOnSegment(currentPos, controlPointPos, nextPos, clickPosBaseRatio, 0.05) - if (result == PointSegmentSearchResult.NOT_FOUND) - continue + let controlPointPos = segment.controlPoint + ? segment.controlPoint + : between(currentPos, nextPos) + + const result = searchOnSegment( + currentPos, + controlPointPos, + nextPos, + clickPosBaseRatio, + 0.05, + ) + if (result == PointSegmentSearchResult.NOT_FOUND) continue segmentInsertionIndex = i - segmentInsertionIsOnRightOfCP = result == PointSegmentSearchResult.RIGHT_TO_CONTROL_POINT + segmentInsertionIsOnRightOfCP = + result == PointSegmentSearchResult.RIGHT_TO_CONTROL_POINT break } - if (segmentInsertionIndex == -1) - return + if (segmentInsertionIndex == -1) return const splicedSegment: Segment = segments[segmentInsertionIndex] - let newSegments: Segment[] - if (segmentInsertionIsOnRightOfCP) { - newSegments = segments.toSpliced( - segmentInsertionIndex, - 1, - {next: clickPosBaseRatio, controlPoint: splicedSegment.controlPoint}, - {next: splicedSegment.next, controlPoint: undefined} - ) - } else { - newSegments = segments.toSpliced( + onSegmentsChanges( + segments.toSpliced( segmentInsertionIndex, 1, - {next: clickPosBaseRatio, controlPoint: undefined}, - {next: splicedSegment.next, controlPoint: splicedSegment.controlPoint} - ) - } - - onSegmentsChanges(newSegments) + { + next: clickPosBaseRatio, + controlPoint: segmentInsertionIsOnRightOfCP + ? splicedSegment.controlPoint + : undefined, + }, + { + next: splicedSegment.next, + controlPoint: segmentInsertionIsOnRightOfCP + ? undefined + : splicedSegment.controlPoint, + }, + ), + ) } - pathRef?.current?.addEventListener('dblclick', addSegment) + pathRef?.current?.addEventListener("dblclick", addSegment) return () => { - pathRef?.current?.removeEventListener('dblclick', addSegment) + pathRef?.current?.removeEventListener("dblclick", addSegment) } - }, [pathRef, segments]); + }, [pathRef, segments, onSegmentsChanges]) return (
+ style={{ position: "absolute", top: 0, left: 0 }}> { - if (e.key == "Delete") onDeleteRequested() + if (onDeleteRequested && e.key == "Delete") + onDeleteRequested() }} />
- {style?.head?.call(style) ?? } + {style?.head?.call(style)}
- {style?.tail?.call(style) ?? } + {style?.tail?.call(style)}
- {isSelected && computeControlPoints(area.current!.getBoundingClientRect())} + {!forceStraight && + isSelected && + computeControlPoints(area.current!.getBoundingClientRect())}
) } @@ -394,13 +417,20 @@ interface ControlPointProps { enum PointSegmentSearchResult { LEFT_TO_CONTROL_POINT, RIGHT_TO_CONTROL_POINT, - NOT_FOUND + NOT_FOUND, } -function searchOnSegment(startPos: Pos, controlPoint: Pos, endPos: Pos, point: Pos, minDistance: number): PointSegmentSearchResult { - - - const step = 1 / ((distance(startPos, controlPoint) + distance(controlPoint, endPos)) / minDistance) +function searchOnSegment( + startPos: Pos, + controlPoint: Pos, + endPos: Pos, + point: Pos, + minDistance: number, +): PointSegmentSearchResult { + const step = + 1 / + ((distance(startPos, controlPoint) + distance(controlPoint, endPos)) / + minDistance) const p0MinusP1 = minus(startPos, controlPoint) const p2MinusP1 = minus(endPos, controlPoint) @@ -408,22 +438,12 @@ function searchOnSegment(startPos: Pos, controlPoint: Pos, endPos: Pos, point: P function getDistanceAt(t: number): number { // apply the bezier function const pos = add( - add( - controlPoint, - mul( - p0MinusP1, - (1 - t) ** 2 - ) - ), - mul( - p2MinusP1, - t ** 2 - ) + add(controlPoint, mul(p0MinusP1, (1 - t) ** 2)), + mul(p2MinusP1, t ** 2), ) return distance(pos, point) } - for (let t = 0; t < 1; t += step) { if (getDistanceAt(t) <= minDistance) return t >= 0.5 @@ -439,18 +459,18 @@ let slice = 0.5 for (let i = 0; i < 100; i++) { t += slice slice /= 2 -// console.log(t) + // console.log(t) } function ArrowPoint({ - className, - posRatio, - parentBase, - onMoves, - onPosValidated, - onRemove, - radius = 7, - }: ControlPointProps) { + className, + posRatio, + parentBase, + onMoves, + onPosValidated, + onRemove, + radius = 7, +}: ControlPointProps) { const ref = useRef(null) const pos = posWithinBase(posRatio, parentBase) @@ -466,7 +486,7 @@ function ArrowPoint({ const pointPos = middlePos(ref.current!.getBoundingClientRect()) onMoves(ratioWithinBase(pointPos, parentBase)) }} - position={{x: pos.x - radius, y: pos.y - radius}}> + position={{ x: pos.x - radius, y: pos.y - radius }}>
p.x) - const allPosY = positions.map(p => p.y) + 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} + 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), + x: pos.x + width / 2, + y: pos.y + height / 2, width, - height + 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) -} \ No newline at end of file + return ( + pos.x >= box.x && + pos.x <= box.x + box.width && + pos.y >= box.y && + pos.y <= box.y + box.height + ) +} diff --git a/front/components/arrows/Pos.ts b/front/components/arrows/Pos.ts index 08ddc7a..4ca08bd 100644 --- a/front/components/arrows/Pos.ts +++ b/front/components/arrows/Pos.ts @@ -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,20 +19,19 @@ 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 } } - 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 minus(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 mul(a: Pos, t: number): Pos { - return {x: a.x * t, y: a.y * t} + return { x: a.x * t, y: a.y * t } } export function distance(a: Pos, b: Pos): number { @@ -61,6 +60,6 @@ export function posWithinBase(ratio: Pos, base: DOMRect): Pos { export function between(a: Pos, b: Pos): Pos { return { x: a.x / 2 + b.x / 2, - y: a.y / 2 + b.y / 2 + y: a.y / 2 + b.y / 2, } -} \ No newline at end of file +} diff --git a/front/components/editor/BasketCourt.tsx b/front/components/editor/BasketCourt.tsx index be8e22d..cfa4bd2 100644 --- a/front/components/editor/BasketCourt.tsx +++ b/front/components/editor/BasketCourt.tsx @@ -9,14 +9,13 @@ import { } from "react" import CourtPlayer from "./CourtPlayer" import { Player } from "../../tactic/Player" -import { Action, MovementActionKind } from "../../tactic/Action" -import RemoveAction from "../actions/RemoveAction" +import { Action, ActionKind } from "../../tactic/Action" import ArrowAction from "../actions/ArrowAction" - -import BendableArrow, { Segment } from "../arrows/BendableArrow" -import { middlePos, NULL_POS, Pos, ratioWithinBase } from "../arrows/Pos" +import { middlePos, ratioWithinBase } from "../arrows/Pos" import BallAction from "../actions/BallAction" import { CourtObject } from "../../tactic/CourtObjects" +import { contains } from "../arrows/Box" +import { CourtAction } from "../../views/editor/CourtAction" export interface BasketCourtProps { players: Player[] @@ -32,7 +31,7 @@ export interface BasketCourtProps { onBallRemove: () => void onBallMoved: (ball: DOMRect) => void - courtImage: () => ReactElement + courtImage: ReactElement courtRef: RefObject } @@ -51,9 +50,16 @@ export function BasketCourt({ courtImage, courtRef, }: BasketCourtProps) { - function placeArrow(originRef: HTMLElement, arrowHead: DOMRect) { + 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 == originRef.id) { + if (player.id == origin.id) { continue } @@ -73,18 +79,12 @@ export function BasketCourt({ .getElementById(player.id)! .getBoundingClientRect() - const courtBounds = courtRef.current!.getBoundingClientRect() - - const start = ratioWithinBase( - middlePos(originRef.getBoundingClientRect()), - courtBounds, - ) const end = ratioWithinBase(middlePos(targetPos), courtBounds) const action: Action = { fromPlayerId: originRef.id, toPlayerId: player.id, - type: MovementActionKind.SCREEN, + type: origin.hasBall ? ActionKind.SHOOT : ActionKind.SCREEN, moveFrom: start, segments: [{ next: end }], } @@ -95,18 +95,19 @@ export function BasketCourt({ const action: Action = { fromPlayerId: originRef.id, - type: MovementActionKind.MOVE, - moveFrom: middlePos(originRef.getBoundingClientRect()), - segments: [{ next: middlePos(arrowHead) }], + type: origin.hasBall ? ActionKind.DRIBBLE : ActionKind.MOVE, + moveFrom: ratioWithinBase( + middlePos(originRef.getBoundingClientRect()), + courtBounds, + ), + segments: [ + { next: ratioWithinBase(middlePos(arrowHead), courtBounds) }, + ], } setActions((actions) => [...actions, action]) } - const [previewArrowOriginPos, setPreviewArrowOriginPos] = - useState(NULL_POS) - const [isPreviewArrowEnabled, setPreviewArrowEnabled] = useState(false) - - const [previewArrowEdges, setPreviewArrowEdges] = useState([]) + const [previewAction, setPreviewAction] = useState(null) const updateActionsRelatedTo = useCallback((player: Player) => { const newPos = ratioWithinBase( @@ -147,9 +148,7 @@ export function BasketCourt({ className="court-container" ref={courtRef} style={{ position: "relative" }}> - {courtImage()} - - {internActions.map((action, idx) => renderAction(action, idx))} + {courtImage} {players.map((player) => ( onPlayerRemove(player)} parentRef={courtRef} availableActions={(pieceRef) => [ - onPlayerRemove(player)} - />, { const baseBounds = courtRef.current!.getBoundingClientRect() - setPreviewArrowEdges([ - { - next: ratioWithinBase( - middlePos(headPos), - baseBounds, + + 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() - setPreviewArrowOriginPos( - ratioWithinBase( + setPreviewAction({ + type: player.hasBall + ? ActionKind.DRIBBLE + : ActionKind.MOVE, + fromPlayerId: player.id, + toPlayerId: undefined, + moveFrom: ratioWithinBase( middlePos( pieceRef.getBoundingClientRect(), ), baseBounds, ), - ) - setPreviewArrowEdges([ - { - next: ratioWithinBase( - middlePos(headPos), - baseBounds, - ), - }, - ]) - setPreviewArrowEnabled(true) + segments: [ + { + next: ratioWithinBase( + middlePos(headPos), + baseBounds, + ), + }, + ], + }) }} onHeadDropped={(headRect) => { - placeArrow(pieceRef, headRect) - setPreviewArrowEnabled(false) + placeArrow(player, headRect) + setPreviewAction(null) }} />, player.hasBall && ( onBallMoved(ref.getBoundingClientRect()) } @@ -217,6 +241,8 @@ export function BasketCourt({ /> ))} + {internActions.map((action, idx) => renderAction(action, idx))} + {objects.map((object) => { if (object.type == "ball") { return ( @@ -231,16 +257,13 @@ export function BasketCourt({ throw new Error("unknown court object", object.type) })} - {isPreviewArrowEnabled && ( - {}} - //TODO place those values in constants - endRadius={17} - startRadius={26} + onActionDeleted={() => {}} + onActionChanges={() => {}} /> )}
diff --git a/front/style/actions/arrow_action.css b/front/style/actions/arrow_action.css index 827503e..3aa88d7 100644 --- a/front/style/actions/arrow_action.css +++ b/front/style/actions/arrow_action.css @@ -2,31 +2,22 @@ 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-action-icon { + user-select: none; + -moz-user-select: none; + max-width: 17px; + max-height: 17px; } .arrow-head-pick { - background-color: red; -} - -.arrow-head-xarrow { - visibility: visible; + position: absolute; + cursor: grab; + top: 0; + left: 0; + min-width: 17px; + min-height: 17px; } -.arrow-action:active .arrow-head-xarrow { - visibility: visible; +.arrow-head-pick:active { + cursor: crosshair; } - -/*.arrow-action:active .arrow-head-pick {*/ -/* min-height: unset;*/ -/* min-width: unset;*/ -/* width: 0;*/ -/* height: 0;*/ -/*}*/ diff --git a/front/style/player.css b/front/style/player.css index 326be7d..22afe4e 100644 --- a/front/style/player.css +++ b/front/style/player.css @@ -41,15 +41,15 @@ position: absolute; flex-direction: row; - justify-content: space-between; + justify-content: space-evenly; align-content: space-between; align-items: center; visibility: hidden; - margin-bottom: 10%; - transform: translateY(-20px); + transform: translateY(-25px); height: 20px; + width: 150%; gap: 25%; } diff --git a/front/tactic/Action.ts b/front/tactic/Action.ts index 993499a..d66f375 100644 --- a/front/tactic/Action.ts +++ b/front/tactic/Action.ts @@ -2,13 +2,14 @@ import { Pos } from "../components/arrows/Pos" import { Segment } from "../components/arrows/BendableArrow" import { PlayerId } from "./Player" -export enum MovementActionKind { +export enum ActionKind { SCREEN = "SCREEN", DRIBBLE = "DRIBBLE", MOVE = "MOVE", + SHOOT = "SHOOT", } -export type Action = { type: MovementActionKind } & MovementAction +export type Action = { type: ActionKind } & MovementAction export interface MovementAction { fromPlayerId: PlayerId diff --git a/front/views/Editor.tsx b/front/views/Editor.tsx index 7bd1a4c..930c99a 100644 --- a/front/views/Editor.tsx +++ b/front/views/Editor.tsx @@ -31,6 +31,7 @@ import { CourtObject } from "../tactic/CourtObjects" import { CourtAction } from "./editor/CourtAction" import { BasketCourt } from "../components/editor/BasketCourt" import { ratioWithinBase } from "../components/arrows/Pos" +import { Action, ActionKind } from "../tactic/Action" const ERROR_STYLE: CSSProperties = { borderColor: "red", @@ -254,16 +255,42 @@ function EditorView({ return -1 } + function updateActions(actions: Action[], players: Player[]) { + return actions.map((action) => { + const originHasBall = players.find( + (p) => p.id == action.fromPlayerId, + )!.hasBall + + let type = action.type + + if (originHasBall && type == ActionKind.MOVE) { + type = ActionKind.DRIBBLE + } else if (originHasBall && type == ActionKind.SCREEN) { + type = ActionKind.SHOOT + } else if (type == ActionKind.DRIBBLE) { + type = ActionKind.MOVE + } else if (type == ActionKind.SHOOT) { + type = ActionKind.SCREEN + } + return { + ...action, + type, + } + }) + } + const onBallDropOnPlayer = (playerCollidedIdx: number) => { setContent((content) => { const ballObj = content.objects.findIndex((o) => o.type == "ball") let player = content.players.at(playerCollidedIdx) as Player + const players = content.players.toSpliced(playerCollidedIdx, 1, { + ...player, + hasBall: true, + }) return { ...content, - players: content.players.toSpliced(playerCollidedIdx, 1, { - ...player, - hasBall: true, - }), + actions: updateActions(content.actions, players), + players, objects: content.objects.toSpliced(ballObj, 1), } }) @@ -303,13 +330,16 @@ function EditorView({ bottomRatio: y, } + const players = content.players.map((player) => ({ + ...player, + hasBall: false, + })) + setContent((content) => { return { ...content, - players: content.players.map((player) => ({ - ...player, - hasBall: false, - })), + actions: updateActions(content.actions, players), + players, objects: [...content.objects, courtObject], } }) @@ -436,7 +466,7 @@ function EditorView({ objects={content.objects} actions={content.actions} onBallMoved={onBallDrop} - courtImage={() => } + courtImage={} courtRef={courtDivContentRef} setActions={(actions) => setContent((content) => ({ diff --git a/front/views/editor/CourtAction.tsx b/front/views/editor/CourtAction.tsx index 7a2e037..44118e3 100644 --- a/front/views/editor/CourtAction.tsx +++ b/front/views/editor/CourtAction.tsx @@ -1,6 +1,11 @@ -import { Action } from "../../tactic/Action" +import { Action, ActionKind } from "../../tactic/Action" import BendableArrow from "../../components/arrows/BendableArrow" import { RefObject } from "react" +import { + MoveToHead, + ScreenHead, + ShootHead, +} from "../../components/actions/ArrowAction" export interface CourtActionProps { action: Action @@ -15,8 +20,31 @@ export function CourtAction({ onActionDeleted, courtRef, }: CourtActionProps) { + let head + switch (action.type) { + case ActionKind.DRIBBLE: + case ActionKind.MOVE: + head = () => + break + case ActionKind.SCREEN: + head = () => + break + case ActionKind.SHOOT: + head = () => + } + + let dashArray + switch (action.type) { + case ActionKind.SHOOT: + dashArray = "10 5" + break + case ActionKind.DRIBBLE: + dashArray = "4" + } + return ( ) } From 768eae92ad29570672b61b7d360500698b77b2f0 Mon Sep 17 00:00:00 2001 From: "maxime.batista" Date: Tue, 26 Dec 2023 18:43:58 +0100 Subject: [PATCH 13/15] add conventional wavy arrow style for dribbles --- front/components/actions/ArrowAction.tsx | 13 - front/components/arrows/BendableArrow.tsx | 426 +++++++++++++++++----- front/components/arrows/Pos.ts | 16 + front/components/editor/BasketCourt.tsx | 2 +- front/components/editor/CourtPlayer.tsx | 14 +- front/style/editor.css | 1 + front/views/editor/CourtAction.tsx | 12 +- 7 files changed, 367 insertions(+), 117 deletions(-) diff --git a/front/components/actions/ArrowAction.tsx b/front/components/actions/ArrowAction.tsx index f1a2cc9..00a661c 100644 --- a/front/components/actions/ArrowAction.tsx +++ b/front/components/actions/ArrowAction.tsx @@ -59,16 +59,3 @@ export function MoveToHead() { ) } - -export function ShootHead() { - return ( - - - - ) -} diff --git a/front/components/arrows/BendableArrow.tsx b/front/components/arrows/BendableArrow.tsx index 7855a10..88680b6 100644 --- a/front/components/arrows/BendableArrow.tsx +++ b/front/components/arrows/BendableArrow.tsx @@ -4,6 +4,7 @@ import { RefObject, useCallback, useEffect, + useLayoutEffect, useRef, useState, } from "react" @@ -18,6 +19,8 @@ import { Pos, posWithinBase, ratioWithinBase, + relativeTo, + size, } from "./Pos" import "../../style/bendable_arrows.css" @@ -29,6 +32,7 @@ export interface BendableArrowProps { segments: Segment[] onSegmentsChanges: (edges: Segment[]) => void forceStraight: boolean + wavy: boolean startRadius?: number endRadius?: number @@ -45,8 +49,8 @@ export interface ArrowStyle { tail?: () => ReactElement } -const ArrowStyleDefaults = { - width: 4, +const ArrowStyleDefaults: ArrowStyle = { + width: 3, } export interface Segment { @@ -54,22 +58,51 @@ export interface Segment { controlPoint?: Pos } -function constraintInCircle(pos: Pos, from: Pos, radius: number): Pos { - const theta = angle(pos, from) +/** + * 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: pos.x - Math.sin(theta) * radius, - y: pos.y - Math.cos(theta) * radius, + 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, @@ -82,18 +115,46 @@ export default function BendableArrow({ const styleWidth = style?.width ?? ArrowStyleDefaults.width - useEffect(() => { - setInternalSegments(segments) - }, [segments]) - - const [internalSegments, setInternalSegments] = useState(segments) + // 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( + () => computeInternalSegments(segments), + ) + // If the (original) segments changes, overwrite the current ones. + useLayoutEffect(() => { + setInternalSegments(computeInternalSegments(segments)) + }, [startPos, segments]) const [isSelected, setIsSelected] = useState(false) const headRef = useRef(null) const tailRef = useRef(null) - function computeControlPoints(parentBase: DOMRect) { + function computeInternalSegments(segments: Segment[]): FullSegment[] { + return segments.map((segment, idx) => { + if (idx == 0) { + return { + start: startPos, + controlPoint: segment.controlPoint, + end: segment.next, + } + } + const start = segments[idx - 1].next + return { + start, + controlPoint: segment.controlPoint, + end: segment.next, + } + }) + } + + /** + * 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 @@ -137,6 +198,7 @@ export default function BendableArrow({ //next pos point (only if this is not the last segment) i != segments.length - 1 && ( { + onMoves={(end) => { setInternalSegments((is) => { - return is.toSpliced(i, 1, { - ...is[i], - next, - }) + return is.toSpliced( + i, + 2, + { + ...is[i], + end, + }, + { + ...is[i + 1], + start: end, + }, + ) }) }} /> @@ -167,33 +237,30 @@ export default function BendableArrow({ }) } + /** + * Updates the states based on given parameters, which causes the arrow to re-render. + */ const update = useCallback(() => { const parentBase = area.current!.getBoundingClientRect() - const firstSegment = internalSegments[0] ?? null - if (firstSegment == null) - throw new Error("segments might not be empty.") + 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.next, parentBase) + const endRelative = posWithinBase(lastSegment.end, parentBase) const startNext = - firstSegment.controlPoint && !forceStraight - ? posWithinBase(firstSegment.controlPoint, parentBase) - : posWithinBase(firstSegment.next, parentBase) + segment.controlPoint && !forceStraight + ? posWithinBase(segment.controlPoint, parentBase) + : posWithinBase(segment.end, parentBase) const endPrevious = forceStraight ? startRelative : lastSegment.controlPoint ? posWithinBase(lastSegment.controlPoint, parentBase) - : internalSegments[internalSegments.length - 2] - ? posWithinBase( - internalSegments[internalSegments.length - 2].next, - parentBase, - ) - : startRelative + : posWithinBase(lastSegment.start, parentBase) const tailPos = constraintInCircle( startRelative, @@ -229,52 +296,95 @@ export default function BendableArrow({ } const segmentsRelatives = ( - forceStraight ? internalSegments.slice(-1) : internalSegments - ).map(({ next, controlPoint }, idx) => { - const nextPos = posWithinBase(next, parentBase) + 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, + ) + : between(startRelative, nextRelative) + return { - next: nextPos, - cp: - controlPoint && !forceStraight - ? posWithinBase(controlPoint, parentBase) - : between( - idx == 0 - ? startRelative - : posWithinBase( - internalSegments[idx - 1].next, - parentBase, - ), - nextPos, - ), + start: startRelative, + end: nextRelative, + cp: controlPointRelative, } }) const computedSegments = segmentsRelatives - .map(({ next: n, cp }, idx) => { - let next = n - - if (idx == internalSegments.length - 1) { + .map(({ start, cp, end: e }, idx) => { + let end = e + if (idx == segmentsRelatives.length - 1) { //if it is the last element - next = constraintInCircle(next, cp, endRadius!) + end = constraintInCircle(end, cp, endRadius!) + } + + const previousSegment = + idx != 0 ? segmentsRelatives[idx - 1] : undefined + + const previousSegmentCpAndCurrentPosVector = minus( + start, + previousSegment?.cp ?? between(start, end), + ) + + const smoothCp = previousSegment + ? add(start, previousSegmentCpAndCurrentPosVector) + : cp + + if (wavy) { + return wavyBezier(start, smoothCp, cp, end, 10, 10) } - return `C${cp.x - left} ${cp.y - top}, ${cp.x - left} ${ - cp.y - top - }, ${next.x - left} ${next.y - top}` + 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]) - + }, [ + 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) } @@ -288,6 +398,7 @@ export default function BendableArrow({ } }, [update, containerRef]) + // Inserts a segment where the mouse double clicks on the arrow useEffect(() => { if (forceStraight) return @@ -304,16 +415,30 @@ export default function BendableArrow({ 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 - let currentPos = i == 0 ? startPos : segments[i - 1].next - let nextPos = segment.next - let controlPointPos = segment.controlPoint + const currentPos = beforeSegment ? beforeSegment.next : startPos + const nextPos = segment.next + const segmentCp = segment.controlPoint ? segment.controlPoint : between(currentPos, nextPos) + const smoothCp = beforeSegment + ? add( + currentPos, + minus( + currentPos, + beforeSegment.controlPoint ?? + between(beforeSegmentPos, currentPos), + ), + ) + : segmentCp + const result = searchOnSegment( currentPos, - controlPointPos, + smoothCp, + segmentCp, nextPos, clickPosBaseRatio, 0.05, @@ -373,7 +498,9 @@ export default function BendableArrow({ ref={pathRef} stroke={"#000"} strokeWidth={styleWidth} - strokeDasharray={style?.dashArray} + strokeDasharray={ + style?.dashArray ?? ArrowStyleDefaults.dashArray + } fill="none" tabIndex={0} onKeyUp={(e) => { @@ -399,7 +526,7 @@ export default function BendableArrow({ {!forceStraight && isSelected && - computeControlPoints(area.current!.getBoundingClientRect())} + computePoints(area.current!.getBoundingClientRect())}
) } @@ -420,32 +547,154 @@ enum PointSegmentSearchResult { NOT_FOUND, } +interface FullSegment { + start: Pos + controlPoint: Pos | undefined + 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 = size(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( - startPos: Pos, - controlPoint: Pos, - endPos: Pos, + start: Pos, + cp1: Pos, + cp2: Pos, + end: Pos, point: Pos, minDistance: number, ): PointSegmentSearchResult { - const step = - 1 / - ((distance(startPos, controlPoint) + distance(controlPoint, endPos)) / - minDistance) - - const p0MinusP1 = minus(startPos, controlPoint) - const p2MinusP1 = minus(endPos, controlPoint) + const dist = distance(start, cp1) + distance(cp1, cp2) + distance(cp2, end) + const step = 1 / (dist / minDistance) function getDistanceAt(t: number): number { - // apply the bezier function - const pos = add( - add(controlPoint, mul(p0MinusP1, (1 - t) ** 2)), - mul(p2MinusP1, t ** 2), - ) - return distance(pos, point) + return distance(cubicBeziers(start, cp1, cp2, end, t), point) } for (let t = 0; t < 1; t += step) { - if (getDistanceAt(t) <= minDistance) + const distance = getDistanceAt(t) + + if (distance <= minDistance) return t >= 0.5 ? PointSegmentSearchResult.RIGHT_TO_CONTROL_POINT : PointSegmentSearchResult.LEFT_TO_CONTROL_POINT @@ -454,14 +703,17 @@ function searchOnSegment( return PointSegmentSearchResult.NOT_FOUND } -let t = 0 -let slice = 0.5 -for (let i = 0; i < 100; i++) { - t += slice - slice /= 2 - // console.log(t) -} - +/** + * 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, diff --git a/front/components/arrows/Pos.ts b/front/components/arrows/Pos.ts index 4ca08bd..5c651cc 100644 --- a/front/components/arrows/Pos.ts +++ b/front/components/arrows/Pos.ts @@ -38,6 +38,15 @@ export function distance(a: Pos, b: Pos): number { return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2) } +export function size(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) @@ -63,3 +72,10 @@ export function between(a: Pos, b: Pos): Pos { y: a.y / 2 + b.y / 2, } } + +export function rotate(vec: Pos, deg: number): Pos { + return { + x: Math.cos(deg * vec.x) - Math.sin(deg * vec.y), + y: Math.sin(deg * vec.x) + Math.cos(deg * vec.y), + } +} diff --git a/front/components/editor/BasketCourt.tsx b/front/components/editor/BasketCourt.tsx index cfa4bd2..e57d669 100644 --- a/front/components/editor/BasketCourt.tsx +++ b/front/components/editor/BasketCourt.tsx @@ -157,7 +157,7 @@ export function BasketCourt({ onDrag={() => updateActionsRelatedTo(player)} onChange={onPlayerChange} onRemove={() => onPlayerRemove(player)} - parentRef={courtRef} + courtRef={courtRef} availableActions={(pieceRef) => [ { player: Player onDrag: () => void onChange: (p: Player) => void onRemove: () => void - parentRef: RefObject + courtRef: RefObject availableActions: (ro: HTMLElement) => A[] } @@ -22,24 +22,24 @@ export default function CourtPlayer
({ onDrag, onChange, onRemove, - parentRef, + courtRef, availableActions, }: PlayerProps) { + const hasBall = player.hasBall 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() + const parentBounds = courtRef.current!.getBoundingClientRect() const { x, y } = ratioWithinBase(pieceBounds, parentBounds) diff --git a/front/style/editor.css b/front/style/editor.css index a305323..258476a 100644 --- a/front/style/editor.css +++ b/front/style/editor.css @@ -93,6 +93,7 @@ } #court-image-div { + position: relative; background-color: white; height: 100%; width: 100%; diff --git a/front/views/editor/CourtAction.tsx b/front/views/editor/CourtAction.tsx index 44118e3..b4028ff 100644 --- a/front/views/editor/CourtAction.tsx +++ b/front/views/editor/CourtAction.tsx @@ -1,11 +1,7 @@ import { Action, ActionKind } from "../../tactic/Action" import BendableArrow from "../../components/arrows/BendableArrow" import { RefObject } from "react" -import { - MoveToHead, - ScreenHead, - ShootHead, -} from "../../components/actions/ArrowAction" +import { MoveToHead, ScreenHead } from "../../components/actions/ArrowAction" export interface CourtActionProps { action: Action @@ -24,13 +20,12 @@ export function CourtAction({ switch (action.type) { case ActionKind.DRIBBLE: case ActionKind.MOVE: + case ActionKind.SHOOT: head = () => break case ActionKind.SCREEN: head = () => break - case ActionKind.SHOOT: - head = () => } let dashArray @@ -38,8 +33,6 @@ export function CourtAction({ case ActionKind.SHOOT: dashArray = "10 5" break - case ActionKind.DRIBBLE: - dashArray = "4" } return ( @@ -51,6 +44,7 @@ export function CourtAction({ 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} From ec46fb78a21134ba72cdb65d64c8ba498cc3deca Mon Sep 17 00:00:00 2001 From: "maxime.batista" Date: Wed, 3 Jan 2024 15:37:12 +0100 Subject: [PATCH 14/15] apply suggestions --- front/components/arrows/BendableArrow.tsx | 28 +++++++++++------------ front/components/arrows/Pos.ts | 10 ++++---- front/components/editor/BallPiece.tsx | 2 -- front/components/editor/CourtPlayer.tsx | 6 ++--- front/components/editor/PlayerPiece.tsx | 1 - front/views/Editor.tsx | 4 ++-- package.json | 2 -- 7 files changed, 24 insertions(+), 29 deletions(-) diff --git a/front/components/arrows/BendableArrow.tsx b/front/components/arrows/BendableArrow.tsx index 88680b6..c69a653 100644 --- a/front/components/arrows/BendableArrow.tsx +++ b/front/components/arrows/BendableArrow.tsx @@ -11,7 +11,7 @@ import { import { add, angle, - between, + middle, distance, middlePos, minus, @@ -20,7 +20,7 @@ import { posWithinBase, ratioWithinBase, relativeTo, - size, + norm, } from "./Pos" import "../../style/bendable_arrows.css" @@ -137,14 +137,14 @@ export default function BendableArrow({ if (idx == 0) { return { start: startPos, - controlPoint: segment.controlPoint, + controlPoint: segment.controlPoint ?? null, end: segment.next, } } const start = segments[idx - 1].next return { start, - controlPoint: segment.controlPoint, + controlPoint: segment.controlPoint ?? null, end: segment.next, } }) @@ -164,15 +164,15 @@ export default function BendableArrow({ const cpPos = controlPoint || ratioWithinBase( - add(between(prevRelative, nextRelative), parentBase), + add(middle(prevRelative, nextRelative), parentBase), parentBase, ) - const setControlPointPos = (newPos: Pos | undefined) => { + const setControlPointPos = (newPos: Pos | null) => { const segment = segments[i] const newSegments = segments.toSpliced(i, 1, { ...segment, - controlPoint: newPos, + controlPoint: newPos ?? undefined, }) onSegmentsChanges(newSegments) } @@ -185,7 +185,7 @@ export default function BendableArrow({ posRatio={cpPos} parentBase={parentBase} onPosValidated={setControlPointPos} - onRemove={() => setControlPointPos(undefined)} + onRemove={() => setControlPointPos(null)} onMoves={(controlPoint) => { setInternalSegments((is) => { return is.toSpliced(i, 1, { @@ -322,7 +322,7 @@ export default function BendableArrow({ posWithinBase(controlPoint, parentBase), svgPosRelativeToBase, ) - : between(startRelative, nextRelative) + : middle(startRelative, nextRelative) return { start: startRelative, @@ -344,7 +344,7 @@ export default function BendableArrow({ const previousSegmentCpAndCurrentPosVector = minus( start, - previousSegment?.cp ?? between(start, end), + previousSegment?.cp ?? middle(start, end), ) const smoothCp = previousSegment @@ -422,7 +422,7 @@ export default function BendableArrow({ const nextPos = segment.next const segmentCp = segment.controlPoint ? segment.controlPoint - : between(currentPos, nextPos) + : middle(currentPos, nextPos) const smoothCp = beforeSegment ? add( @@ -430,7 +430,7 @@ export default function BendableArrow({ minus( currentPos, beforeSegment.controlPoint ?? - between(beforeSegmentPos, currentPos), + middle(beforeSegmentPos, currentPos), ), ) : segmentCp @@ -549,7 +549,7 @@ enum PointSegmentSearchResult { interface FullSegment { start: Pos - controlPoint: Pos | undefined + controlPoint: Pos | null end: Pos } @@ -573,7 +573,7 @@ function wavyBezier( ): string { function getVerticalAmplification(t: number): Pos { const velocity = cubicBeziersDerivative(start, cp1, cp2, end, t) - const velocityLength = size(velocity) + const velocityLength = norm(velocity) //rotate the velocity by 90 deg const projection = { x: velocity.y, y: -velocity.x } diff --git a/front/components/arrows/Pos.ts b/front/components/arrows/Pos.ts index 5c651cc..be7a704 100644 --- a/front/components/arrows/Pos.ts +++ b/front/components/arrows/Pos.ts @@ -38,7 +38,7 @@ export function distance(a: Pos, b: Pos): number { return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2) } -export function size(vector: Pos): number { +export function norm(vector: Pos): number { return distance(NULL_POS, vector) } @@ -66,16 +66,16 @@ export function posWithinBase(ratio: Pos, base: DOMRect): Pos { } } -export function between(a: Pos, b: Pos): Pos { +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, deg: number): Pos { +export function rotate(vec: Pos, rad: number): Pos { return { - x: Math.cos(deg * vec.x) - Math.sin(deg * vec.y), - y: Math.sin(deg * vec.x) + Math.cos(deg * vec.y), + x: Math.cos(rad) * vec.x - Math.sin(rad) * vec.y, + y: Math.sin(rad) * vec.x + Math.cos(rad) * vec.y, } } diff --git a/front/components/editor/BallPiece.tsx b/front/components/editor/BallPiece.tsx index c28b71b..2741249 100644 --- a/front/components/editor/BallPiece.tsx +++ b/front/components/editor/BallPiece.tsx @@ -1,5 +1,3 @@ -import React from "react" - import "../../style/ball.css" import BallSvg from "../../assets/icon/ball.svg?react" diff --git a/front/components/editor/CourtPlayer.tsx b/front/components/editor/CourtPlayer.tsx index 2cda286..aa120b2 100644 --- a/front/components/editor/CourtPlayer.tsx +++ b/front/components/editor/CourtPlayer.tsx @@ -5,13 +5,13 @@ import { PlayerPiece } from "./PlayerPiece" import { Player } from "../../tactic/Player" import { NULL_POS, ratioWithinBase } from "../arrows/Pos" -export interface PlayerProps { +export interface PlayerProps { player: Player onDrag: () => void onChange: (p: Player) => void onRemove: () => void courtRef: RefObject - availableActions: (ro: HTMLElement) => A[] + availableActions: (ro: HTMLElement) => ReactNode[] } /** @@ -24,7 +24,7 @@ export default function CourtPlayer({ onRemove, courtRef, availableActions, -}: PlayerProps) { +}: PlayerProps) { const hasBall = player.hasBall const x = player.rightRatio const y = player.bottomRatio diff --git a/front/components/editor/PlayerPiece.tsx b/front/components/editor/PlayerPiece.tsx index e725d31..a1b5e74 100644 --- a/front/components/editor/PlayerPiece.tsx +++ b/front/components/editor/PlayerPiece.tsx @@ -1,4 +1,3 @@ -import React from "react" import "../../style/player.css" import { Team } from "../../tactic/Team" diff --git a/front/views/Editor.tsx b/front/views/Editor.tsx index 930c99a..69c1eca 100644 --- a/front/views/Editor.tsx +++ b/front/views/Editor.tsx @@ -571,8 +571,8 @@ function debounceAsync( let task = 0 return (args: A) => { clearTimeout(task) - return new Promise((resolve) => { - task = setTimeout(() => f(args).then(resolve), delay) + return new Promise((resolve, reject) => { + task = setTimeout(() => f(args).then(resolve).catch(reject), delay) }) } } diff --git a/package.json b/package.json index 9c3a9d8..1380d59 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,6 @@ "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" @@ -33,7 +32,6 @@ }, "devDependencies": { "@vitejs/plugin-react": "^4.1.0", - "eslint-plugin-react-hooks": "^4.6.0", "prettier": "^3.1.0", "typescript": "^5.2.2", "vite-plugin-svgr": "^4.1.0" From 1e6978be6e11984cdcb4def64a3d53164a656243 Mon Sep 17 00:00:00 2001 From: "maxime.batista" Date: Thu, 4 Jan 2024 17:34:40 +0100 Subject: [PATCH 15/15] apply suggestions --- .gitignore | 1 + front/components/arrows/BendableArrow.tsx | 61 +++++++++++------------ front/components/editor/CourtPlayer.tsx | 4 +- sql/.guard | 0 4 files changed, 32 insertions(+), 34 deletions(-) delete mode 100644 sql/.guard diff --git a/.gitignore b/.gitignore index 61df6e7..a02dfdf 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ vendor composer.lock *.phar /dist +.guard # sqlite database files *.sqlite diff --git a/front/components/arrows/BendableArrow.tsx b/front/components/arrows/BendableArrow.tsx index c69a653..6ecdb44 100644 --- a/front/components/arrows/BendableArrow.tsx +++ b/front/components/arrows/BendableArrow.tsx @@ -7,6 +7,7 @@ import { useLayoutEffect, useRef, useState, + MouseEvent as ReactMouseEvent, } from "react" import { add, @@ -115,6 +116,24 @@ export default function BendableArrow({ 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 @@ -125,30 +144,14 @@ export default function BendableArrow({ // If the (original) segments changes, overwrite the current ones. useLayoutEffect(() => { setInternalSegments(computeInternalSegments(segments)) - }, [startPos, segments]) + }, [startPos, segments, computeInternalSegments]) const [isSelected, setIsSelected] = useState(false) const headRef = useRef(null) const tailRef = useRef(null) - function computeInternalSegments(segments: Segment[]): FullSegment[] { - 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, - } - }) - } + /** * Computes and return the segments edition points @@ -398,14 +401,12 @@ export default function BendableArrow({ } }, [update, containerRef]) - // Inserts a segment where the mouse double clicks on the arrow - useEffect(() => { - if (forceStraight) return - - const addSegment = (e: MouseEvent) => { + const addSegment = useCallback( + (e: ReactMouseEvent) => { + if (forceStraight) return const parentBase = area.current!.getBoundingClientRect() - const clickAbsolutePos: Pos = { x: e.x, y: e.y } + const clickAbsolutePos: Pos = { x: e.pageX, y: e.pageY } const clickPosBaseRatio = ratioWithinBase( clickAbsolutePos, parentBase, @@ -473,14 +474,9 @@ export default function BendableArrow({ }, ), ) - } - - pathRef?.current?.addEventListener("dblclick", addSegment) - - return () => { - pathRef?.current?.removeEventListener("dblclick", addSegment) - } - }, [pathRef, segments, onSegmentsChanges]) + }, + [area, forceStraight, onSegmentsChanges, segments, startPos], + ) return (