Merge pull request 'Add steps to the editor' (#114) from editor/steps into master
continuous-integration/drone/push Build is passing Details

Reviewed-on: #114
pull/116/head
Maxime BATISTA 1 year ago committed by maxime
commit cca7ee1b1b

@ -1,4 +1,5 @@
set -e #!/usr/bin/env bash
set -xeu
export OUTPUT=$1 export OUTPUT=$1
export BASE=$2 export BASE=$2
@ -10,9 +11,9 @@ echo "VITE_BASE=$BASE" >> .env.PROD
ci/build_react.msh ci/build_react.msh
mkdir -p $OUTPUT/profiles/ mkdir -p "$OUTPUT"/profiles/
sed -E 's/\/\*PROFILE_FILE\*\/\s*".*"/"profiles\/prod-config-profile.php"/' config.php > $OUTPUT/config.php sed -E 's/\/\*PROFILE_FILE\*\/\s*".*"/"profiles\/prod-config-profile.php"/' config.php > "$OUTPUT"/config.php
sed -E "s/const BASE_PATH = .*;/const BASE_PATH = \"$(sed s/\\//\\\\\\//g <<< "$BASE")\";/" profiles/prod-config-profile.php > $OUTPUT/profiles/prod-config-profile.php sed -E "s/const BASE_PATH = .*;/const BASE_PATH = \"$(sed s/\\//\\\\\\//g <<< "$BASE")\";/" profiles/prod-config-profile.php > $OUTPUT/profiles/prod-config-profile.php
cp -r vendor sql src public $OUTPUT cp -r vendor sql src public "$OUTPUT"

@ -7,8 +7,6 @@
"@testing-library/jest-dom": "^5.17.0", "@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0", "@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.59",
"@types/react": "^18.2.31", "@types/react": "^18.2.31",
"@types/react-dom": "^18.2.14", "@types/react-dom": "^18.2.14",
"eslint-plugin-react-refresh": "^0.4.5", "eslint-plugin-react-refresh": "^0.4.5",
@ -23,7 +21,7 @@
"scripts": { "scripts": {
"start": "vite --host", "start": "vite --host",
"build": "vite build", "build": "vite build",
"test": "vite test", "test": "vitest",
"format": "prettier --config .prettierrc '.' --write", "format": "prettier --config .prettierrc '.' --write",
"tsc": "tsc" "tsc": "tsc"
}, },
@ -34,8 +32,10 @@
"eslint": "^8.53.0", "eslint": "^8.53.0",
"eslint-plugin-react": "^7.33.2", "eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"jsdom": "^24.0.0",
"prettier": "^3.1.0", "prettier": "^3.1.0",
"typescript": "^5.2.2", "typescript": "^5.2.2",
"vite-plugin-svgr": "^4.1.0" "vite-plugin-svgr": "^4.1.0",
"vitest": "^1.3.1"
} }
} }

@ -2,7 +2,7 @@ import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom"
import { Header } from "./pages/template/Header.tsx" import { Header } from "./pages/template/Header.tsx"
import "./style/app.css" import "./style/app.css"
import { lazy, ReactNode, Suspense, useEffect } from "react" import { lazy, ReactNode, Suspense } from "react"
import { BASE } from "./Constants.ts" import { BASE } from "./Constants.ts"
const HomePage = lazy(() => import("./pages/HomePage.tsx")) const HomePage = lazy(() => import("./pages/HomePage.tsx"))
@ -15,7 +15,6 @@ const NewTacticPage = lazy(() => import("./pages/NewTacticPage.tsx"))
const Editor = lazy(() => import("./pages/Editor.tsx")) const Editor = lazy(() => import("./pages/Editor.tsx"))
export default function App() { export default function App() {
function suspense(node: ReactNode) { function suspense(node: ReactNode) {
return ( return (
<Suspense fallback={<p>Loading, please wait...</p>}> <Suspense fallback={<p>Loading, please wait...</p>}>

@ -61,6 +61,7 @@ async function handleResponse(
const expirationDate = Date.parse( const expirationDate = Date.parse(
response.headers.get("Next-Authorization-Expiration-Date")!, response.headers.get("Next-Authorization-Expiration-Date")!,
) )
if (nextToken && expirationDate)
saveSession({ ...session, auth: { token: nextToken, expirationDate } }) saveSession({ ...session, auth: { token: nextToken, expirationDate } })
return response return response

@ -1,5 +1 @@
<svg viewBox="0 0 80 49" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M240-440q-17 0-28.5-11.5T200-480q0-17 11.5-28.5T240-520h480q17 0 28.5 11.5T760-480q0 17-11.5 28.5T720-440H240Z"/></svg>
<path d="M24.5 4.5H55.5C66.5457 4.5 75.5 13.4543 75.5 24.5C75.5 35.5457 66.5457 44.5 55.5 44.5H24.5C13.4543 44.5 4.5 35.5457 4.5 24.5C4.5 13.4543 13.4543 4.5 24.5 4.5Z"
stroke="black" stroke-width="9"/>
<line x1="24.5" y1="24.5" x2="55.5" y2="24.5" stroke="black" stroke-width="9" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 405 B

After

Width:  |  Height:  |  Size: 216 B

@ -4,7 +4,7 @@ import Draggable from "react-draggable"
export interface RackProps<E extends { key: string | number }> { export interface RackProps<E extends { key: string | number }> {
id: string id: string
objects: E[] objects: E[]
onChange: (objects: E[]) => void onChange?: (objects: E[]) => void
canDetach: (ref: HTMLDivElement) => boolean canDetach: (ref: HTMLDivElement) => boolean
onElementDetached: (ref: HTMLDivElement, el: E) => void onElementDetached: (ref: HTMLDivElement, el: E) => void
render: (e: E) => ReactElement render: (e: E) => ReactElement
@ -44,7 +44,7 @@ export function Rack<E extends { key: string | number }>({
const index = objects.findIndex( const index = objects.findIndex(
(o) => o.key === element.key, (o) => o.key === element.key,
) )
onChange(objects.toSpliced(index, 1)) if (onChange) onChange(objects.toSpliced(index, 1))
onElementDetached(ref, element) onElementDetached(ref, element)
}} }}

@ -0,0 +1,56 @@
import React, { ReactNode, useCallback, useRef, useState } from "react"
export interface SplitLayoutProps {
children: [ReactNode, ReactNode]
rightWidth: number
onRightWidthChange: (w: number) => void
}
export default function SplitLayout({
children,
rightWidth,
onRightWidthChange,
}: SplitLayoutProps) {
const curtainRef = useRef<HTMLDivElement>(null)
const resize = useCallback(
(e: React.MouseEvent) => {
const sliderPosX = e.clientX
const curtainWidth =
curtainRef.current!.getBoundingClientRect().width
onRightWidthChange((sliderPosX / curtainWidth) * 100)
},
[curtainRef, onRightWidthChange],
)
const [resizing, setResizing] = useState(false)
return (
<div
className={"curtain"}
ref={curtainRef}
style={{ display: "flex" }}
onMouseMove={resizing ? resize : undefined}
onMouseUp={() => setResizing(false)}>
<div className={"curtain-left"} style={{ width: `${rightWidth}%` }}>
{children[0]}
</div>
<div
onMouseDown={() => setResizing(true)}
style={{
width: 4,
height: "100%",
backgroundColor: "grey",
cursor: "col-resize",
userSelect: "none",
}}></div>
<div
className={"curtain-right"}
style={{ width: `${100 - rightWidth}%` }}>
{children[1]}
</div>
</div>
)
}

@ -27,6 +27,7 @@ import {
import "../../style/bendable_arrows.css" import "../../style/bendable_arrows.css"
import Draggable from "react-draggable" import Draggable from "react-draggable"
import { Segment } from "../../model/tactic/Action.ts"
export interface BendableArrowProps { export interface BendableArrowProps {
area: RefObject<HTMLElement> area: RefObject<HTMLElement>
@ -57,11 +58,6 @@ const ArrowStyleDefaults: ArrowStyle = {
color: "black", color: "black",
} }
export interface Segment {
next: Pos | string
controlPoint?: Pos
}
/** /**
* Given a circle shaped by a central position, and a radius, return * Given a circle shaped by a central position, and a radius, return
* a position that is constrained on its perimeter, pointing to the direction * a position that is constrained on its perimeter, pointing to the direction
@ -389,6 +385,7 @@ export default function BendableArrow({
useEffect(() => { useEffect(() => {
const observer = new MutationObserver(update) const observer = new MutationObserver(update)
const config = { attributes: true } const config = { attributes: true }
if (typeof startPos == "string") { if (typeof startPos == "string") {
observer.observe(document.getElementById(startPos)!, config) observer.observe(document.getElementById(startPos)!, config)
} }
@ -402,6 +399,14 @@ export default function BendableArrow({
return () => observer.disconnect() return () => observer.disconnect()
}, [startPos, segments, update]) }, [startPos, segments, update])
useEffect(() => {
const observer = new ResizeObserver(update)
observer.observe(area.current!, {})
return () => observer.disconnect()
})
// Adds a selection handler // Adds a selection handler
// Also force an update when the window is resized // Also force an update when the window is resized
useEffect(() => { useEffect(() => {

@ -1,7 +1,7 @@
import { ReactElement, ReactNode, RefObject } from "react" import { ReactElement, ReactNode, RefObject } from "react"
import { Action } from "../../model/tactic/Action" import { Action } from "../../model/tactic/Action"
import { CourtAction } from "./CourtAction.tsx" import { CourtAction } from "./CourtAction"
import { ComponentId, TacticComponent } from "../../model/tactic/Tactic" import { ComponentId, TacticComponent } from "../../model/tactic/Tactic"
export interface BasketCourtProps { export interface BasketCourtProps {

@ -1,7 +1,7 @@
import { Action, ActionKind } from "../../model/tactic/Action" import { Action, ActionKind } from "../../model/tactic/Action"
import BendableArrow from "../../components/arrows/BendableArrow" import BendableArrow from "../arrows/BendableArrow"
import { RefObject } from "react" import { RefObject } from "react"
import { MoveToHead, ScreenHead } from "../../components/actions/ArrowAction" import { MoveToHead, ScreenHead } from "../actions/ArrowAction"
import { ComponentId } from "../../model/tactic/Tactic" import { ComponentId } from "../../model/tactic/Tactic"
export interface CourtActionProps { export interface CourtActionProps {

@ -1,34 +1,36 @@
import { useRef } from "react" import { KeyboardEventHandler, RefObject, useRef } from "react"
import Draggable from "react-draggable" import Draggable from "react-draggable"
import { BallPiece } from "./BallPiece" import { BallPiece } from "./BallPiece"
import { NULL_POS } from "../../geo/Pos" import { NULL_POS, Pos } from "../../geo/Pos"
import { Ball } from "../../model/tactic/CourtObjects" import { Ball } from "../../model/tactic/CourtObjects"
export interface CourtBallProps { export interface CourtBallProps {
ball: Ball
}
export interface EditableCourtBallProps extends CourtBallProps {
onPosValidated: (rect: DOMRect) => void onPosValidated: (rect: DOMRect) => void
onRemove: () => void onRemove: () => void
ball: Ball
} }
export function CourtBall({ onPosValidated, ball, onRemove }: CourtBallProps) { export function CourtBall({
onPosValidated,
ball,
onRemove,
}: EditableCourtBallProps) {
const pieceRef = useRef<HTMLDivElement>(null) const pieceRef = useRef<HTMLDivElement>(null)
const { x, y } = ball.pos function courtBallPiece(
{ x, y }: Pos,
pieceRef?: RefObject<HTMLDivElement>,
onKeyUp?: KeyboardEventHandler,
) {
return ( return (
<Draggable
onStop={() =>
onPosValidated(pieceRef.current!.getBoundingClientRect())
}
position={NULL_POS}
nodeRef={pieceRef}>
<div <div
className={"ball-div"} className={"ball-div"}
ref={pieceRef} ref={pieceRef}
tabIndex={0} tabIndex={0}
onKeyUp={(e) => { onKeyUp={onKeyUp}
if (e.key == "Delete") onRemove()
}}
style={{ style={{
position: "absolute", position: "absolute",
left: `${x * 100}%`, left: `${x * 100}%`,
@ -36,6 +38,23 @@ export function CourtBall({ onPosValidated, ball, onRemove }: CourtBallProps) {
}}> }}>
<BallPiece /> <BallPiece />
</div> </div>
)
}
if (ball.frozen) {
return courtBallPiece(ball.pos)
}
return (
<Draggable
onStop={() =>
onPosValidated(pieceRef.current!.getBoundingClientRect())
}
position={NULL_POS}
nodeRef={pieceRef}>
{courtBallPiece(ball.pos, pieceRef, (e) => {
if (e.key == "Delete") onRemove()
})}
</Draggable> </Draggable>
) )
} }

@ -1,4 +1,10 @@
import React, { ReactNode, RefObject, useCallback, useRef } from "react" import React, {
KeyboardEventHandler,
ReactNode,
RefObject,
useCallback,
useRef,
} from "react"
import "../../style/player.css" import "../../style/player.css"
import Draggable from "react-draggable" import Draggable from "react-draggable"
import { PlayerPiece } from "./PlayerPiece" import { PlayerPiece } from "./PlayerPiece"
@ -8,38 +14,53 @@ import { NULL_POS, Pos, ratioWithinBase } from "../../geo/Pos"
export interface CourtPlayerProps { export interface CourtPlayerProps {
playerInfo: PlayerInfo playerInfo: PlayerInfo
className?: string className?: string
availableActions: (ro: HTMLElement) => ReactNode[]
}
export interface EditableCourtPlayerProps extends CourtPlayerProps {
courtRef: RefObject<HTMLElement>
onPositionValidated: (newPos: Pos) => void onPositionValidated: (newPos: Pos) => void
onRemove: () => void onRemove: () => void
courtRef: RefObject<HTMLElement>
availableActions: (ro: HTMLElement) => ReactNode[]
} }
const MOVE_AREA_SENSIBILITY = 0.001 const MOVE_AREA_SENSIBILITY = 0.001
export const PLAYER_RADIUS_PIXELS = 20 export const PLAYER_RADIUS_PIXELS = 20
export function CourtPlayer({
playerInfo,
className,
availableActions,
}: CourtPlayerProps) {
const pieceRef = useRef<HTMLDivElement>(null)
return courtPlayerPiece({
playerInfo,
pieceRef,
className,
availableActions: () => availableActions(pieceRef.current!),
})
}
/** /**
* A player that is placed on the court, which can be selected, and moved in the associated bounds * A player that is placed on the court, which can be selected, and moved in the associated bounds
* */ * */
export default function CourtPlayer({ export function EditableCourtPlayer({
playerInfo, playerInfo,
className, className,
courtRef,
onPositionValidated, onPositionValidated,
onRemove, onRemove,
courtRef,
availableActions, availableActions,
}: CourtPlayerProps) { }: EditableCourtPlayerProps) {
const usesBall = playerInfo.ballState != BallState.NONE
const { x, y } = playerInfo.pos
const pieceRef = useRef<HTMLDivElement>(null) const pieceRef = useRef<HTMLDivElement>(null)
const { x, y } = playerInfo.pos
return ( return (
<Draggable <Draggable
handle=".player-piece" handle=".player-piece"
nodeRef={pieceRef} nodeRef={pieceRef}
//The piece is positioned using top/bottom style attributes instead
position={NULL_POS} position={NULL_POS}
onStop={useCallback(() => { onStop={useCallback(() => {
const pieceBounds = pieceRef.current!.getBoundingClientRect() const pieceBounds = pieceRef.current!.getBoundingClientRect()
@ -50,30 +71,58 @@ export default function CourtPlayer({
if ( if (
Math.abs(pos.x - x) >= MOVE_AREA_SENSIBILITY || Math.abs(pos.x - x) >= MOVE_AREA_SENSIBILITY ||
Math.abs(pos.y - y) >= MOVE_AREA_SENSIBILITY Math.abs(pos.y - y) >= MOVE_AREA_SENSIBILITY
) ) {
onPositionValidated(pos) onPositionValidated(pos)
}
}, [courtRef, onPositionValidated, x, y])}> }, [courtRef, onPositionValidated, x, y])}>
{courtPlayerPiece({
playerInfo,
className,
pieceRef,
availableActions: () => availableActions(pieceRef.current!),
onKeyUp: useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key == "Delete") onRemove()
},
[onRemove],
),
})}
</Draggable>
)
}
interface CourtPlayerPieceProps {
playerInfo: PlayerInfo
className?: string
pieceRef?: RefObject<HTMLDivElement>
availableActions?: () => ReactNode[]
onKeyUp?: KeyboardEventHandler<HTMLDivElement>
}
function courtPlayerPiece({
playerInfo,
className,
pieceRef,
onKeyUp,
availableActions,
}: CourtPlayerPieceProps) {
const usesBall = playerInfo.ballState != BallState.NONE
const { x, y } = playerInfo.pos
return (
<div <div
id={playerInfo.id}
ref={pieceRef} ref={pieceRef}
id={playerInfo.id}
className={"player " + (className ?? "")} className={"player " + (className ?? "")}
style={{ style={{
position: "absolute", position: "absolute",
left: `${x * 100}%`, left: `${x * 100}%`,
top: `${y * 100}%`, top: `${y * 100}%`,
}}> }}>
<div <div tabIndex={0} className="player-content" onKeyUp={onKeyUp}>
tabIndex={0} {availableActions && (
className="player-content" <div className="player-actions">{availableActions()}</div>
onKeyUp={useCallback( )}
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key == "Delete") onRemove()
},
[onRemove],
)}>
<div className="player-actions">
{availableActions(pieceRef.current!)}
</div>
<PlayerPiece <PlayerPiece
team={playerInfo.team} team={playerInfo.team}
text={playerInfo.role} text={playerInfo.role}
@ -81,6 +130,5 @@ export default function CourtPlayer({
/> />
</div> </div>
</div> </div>
</Draggable>
) )
} }

@ -0,0 +1,154 @@
import "../../style/steps_tree.css"
import { StepInfoNode } from "../../model/tactic/Tactic"
import BendableArrow from "../arrows/BendableArrow"
import { ReactNode, useMemo, useRef } from "react"
import AddSvg from "../../assets/icon/add.svg?react"
import RemoveSvg from "../../assets/icon/remove.svg?react"
import { getStepName } from "../../editor/StepsDomain.ts"
export interface StepsTreeProps {
root: StepInfoNode
selectedStepId: number
onAddChildren: (parent: StepInfoNode) => void
onRemoveNode: (node: StepInfoNode) => void
onStepSelected: (node: StepInfoNode) => void
}
export default function StepsTree({
root,
selectedStepId,
onAddChildren,
onRemoveNode,
onStepSelected,
}: StepsTreeProps) {
return (
<div className="steps-tree">
<StepsTreeNode
node={root}
rootNode={root}
selectedStepId={selectedStepId}
onAddChildren={onAddChildren}
onRemoveNode={onRemoveNode}
onStepSelected={onStepSelected}
/>
</div>
)
}
interface StepsTreeContentProps {
node: StepInfoNode
rootNode: StepInfoNode
selectedStepId: number
onAddChildren: (parent: StepInfoNode) => void
onRemoveNode: (node: StepInfoNode) => void
onStepSelected: (node: StepInfoNode) => void
}
function StepsTreeNode({
node,
rootNode,
selectedStepId,
onAddChildren,
onRemoveNode,
onStepSelected,
}: StepsTreeContentProps) {
const ref = useRef<HTMLDivElement>(null)
return (
<div ref={ref} className={"step-group"}>
{node.children.map((child) => (
<BendableArrow
key={child.id}
area={ref}
startPos={"step-piece-" + node.id}
segments={[
{
next: "step-piece-" + child.id,
},
]}
onSegmentsChanges={() => {}}
forceStraight={true}
wavy={false}
//TODO remove magic constants
startRadius={10}
endRadius={10}
/>
))}
<StepPiece
id={node.id}
isSelected={selectedStepId === node.id}
onAddButtonClicked={() => onAddChildren(node)}
onRemoveButtonClicked={
rootNode.id === node.id
? undefined
: () => onRemoveNode(node)
}
onSelected={() => onStepSelected(node)}>
<p>
{useMemo(
() => getStepName(rootNode, node.id),
[node.id, rootNode],
)}
</p>
</StepPiece>
<div className={"step-children"}>
{node.children.map((child) => (
<StepsTreeNode
key={child.id}
rootNode={rootNode}
selectedStepId={selectedStepId}
node={child}
onAddChildren={onAddChildren}
onRemoveNode={onRemoveNode}
onStepSelected={onStepSelected}
/>
))}
</div>
</div>
)
}
interface StepPieceProps {
id: number
isSelected: boolean
onAddButtonClicked?: () => void
onRemoveButtonClicked?: () => void
onSelected: () => void
children?: ReactNode
}
function StepPiece({
id,
isSelected,
onAddButtonClicked,
onRemoveButtonClicked,
onSelected,
children,
}: StepPieceProps) {
return (
<div
id={"step-piece-" + id}
tabIndex={1}
className={
"step-piece " + (isSelected ? "step-piece-selected" : "")
}
onClick={onSelected}>
<div className="step-piece-actions">
{onAddButtonClicked && (
<AddSvg
onClick={onAddButtonClicked}
className={"add-icon"}
/>
)}
{onRemoveButtonClicked && (
<RemoveSvg
onClick={onRemoveButtonClicked}
className={"remove-icon"}
/>
)}
</div>
{children}
</div>
)
}

@ -7,15 +7,14 @@ import {
import { ratioWithinBase } from "../geo/Pos" import { ratioWithinBase } from "../geo/Pos"
import { import {
ComponentId, ComponentId,
StepContent,
TacticComponent, TacticComponent,
TacticContent,
} from "../model/tactic/Tactic" } from "../model/tactic/Tactic"
import { overlaps } from "../geo/Box" import { overlaps } from "../geo/Box"
import { Action, ActionKind, moves } from "../model/tactic/Action" import { Action, ActionKind, moves } from "../model/tactic/Action"
import { removeBall, updateComponent } from "./TacticContentDomains" import { removeBall, updateComponent } from "./TacticContentDomains"
import { import {
areInSamePath, areInSamePath,
changePlayerBallState,
getComponent, getComponent,
getOrigin, getOrigin,
getPlayerNextTo, getPlayerNextTo,
@ -212,8 +211,8 @@ export function createAction(
origin: PlayerLike, origin: PlayerLike,
courtBounds: DOMRect, courtBounds: DOMRect,
arrowHead: DOMRect, arrowHead: DOMRect,
content: TacticContent, content: StepContent,
): { createdAction: Action; newContent: TacticContent } { ): { createdAction: Action; newContent: StepContent } {
/** /**
* Creates a new phantom component. * Creates a new phantom component.
* Be aware that this function will reassign the `content` parameter. * Be aware that this function will reassign the `content` parameter.
@ -371,8 +370,8 @@ export function createAction(
export function removeAllActionsTargeting( export function removeAllActionsTargeting(
componentId: ComponentId, componentId: ComponentId,
content: TacticContent, content: StepContent,
): TacticContent { ): StepContent {
const components = [] const components = []
for (let i = 0; i < content.components.length; i++) { for (let i = 0; i < content.components.length; i++) {
const component = content.components[i] const component = content.components[i]
@ -391,9 +390,10 @@ export function removeAllActionsTargeting(
export function removeAction( export function removeAction(
origin: TacticComponent, origin: TacticComponent,
actionIdx: number, actionIdx: number,
content: TacticContent, content: StepContent,
): TacticContent { ): StepContent {
const action = origin.actions[actionIdx] const action = origin.actions[actionIdx]
origin = { origin = {
...origin, ...origin,
actions: origin.actions.toSpliced(actionIdx, 1), actions: origin.actions.toSpliced(actionIdx, 1),
@ -410,20 +410,27 @@ export function removeAction(
(origin.type === "player" || origin.type === "phantom") (origin.type === "player" || origin.type === "phantom")
) { ) {
if (target.type === "player" || target.type === "phantom") if (target.type === "player" || target.type === "phantom")
content = changePlayerBallState(target, BallState.NONE, content) content =
spreadNewStateFromOriginStateChange(
target,
BallState.NONE,
content,
) ?? content
if (origin.ballState === BallState.PASSED) { if (origin.ballState === BallState.PASSED) {
content = changePlayerBallState( content =
spreadNewStateFromOriginStateChange(
origin, origin,
BallState.HOLDS_BY_PASS, BallState.HOLDS_BY_PASS,
content, content,
) ) ?? content
} else if (origin.ballState === BallState.PASSED_ORIGIN) { } else if (origin.ballState === BallState.PASSED_ORIGIN) {
content = changePlayerBallState( content =
spreadNewStateFromOriginStateChange(
origin, origin,
BallState.HOLDS_ORIGIN, BallState.HOLDS_ORIGIN,
content, content,
) ) ?? content
} }
} }
@ -458,14 +465,15 @@ export function removeAction(
* @param origin * @param origin
* @param newState * @param newState
* @param content * @param content
* @returns the new state if it has been updated, or null if no changes were operated
*/ */
export function spreadNewStateFromOriginStateChange( export function spreadNewStateFromOriginStateChange(
origin: PlayerLike, origin: PlayerLike,
newState: BallState, newState: BallState,
content: TacticContent, content: StepContent,
): TacticContent { ): StepContent | null {
if (origin.ballState === newState) { if (origin.ballState === newState) {
return content return null
} }
origin = { origin = {
@ -551,11 +559,12 @@ export function spreadNewStateFromOriginStateChange(
content = updateComponent(origin, content) content = updateComponent(origin, content)
} }
content = spreadNewStateFromOriginStateChange( content =
spreadNewStateFromOriginStateChange(
actionTarget, actionTarget,
targetState, targetState,
content, content,
) ) ?? content
} }
return content return content

@ -6,9 +6,10 @@ import {
} from "../model/tactic/Player" } from "../model/tactic/Player"
import { import {
ComponentId, ComponentId,
StepContent,
TacticComponent, TacticComponent,
TacticContent,
} from "../model/tactic/Tactic" } from "../model/tactic/Tactic"
import { removeComponent, updateComponent } from "./TacticContentDomains" import { removeComponent, updateComponent } from "./TacticContentDomains"
import { import {
removeAllActionsTargeting, removeAllActionsTargeting,
@ -54,11 +55,22 @@ export function getPlayerNextTo(
: getComponent<PlayerLike>(pathItems[targetIdx - 1], components) : getComponent<PlayerLike>(pathItems[targetIdx - 1], components)
} }
//FIXME this function can be a bottleneck if the phantom's position is export function getPrecomputedPosition(
// following another phantom and / or the origin of the phantom is another phantom: PlayerPhantom,
computedPositions: Map<string, Pos>,
): Pos | undefined {
const pos = phantom.pos
// If the position is already known and fixed, return the pos
if (pos.type === "fixed") return pos
return computedPositions.get(phantom.id)
}
export function computePhantomPositioning( export function computePhantomPositioning(
phantom: PlayerPhantom, phantom: PlayerPhantom,
content: TacticContent, content: StepContent,
computedPositions: Map<string, Pos>,
area: DOMRect, area: DOMRect,
): Pos { ): Pos {
const positioning = phantom.pos const positioning = phantom.pos
@ -66,6 +78,9 @@ export function computePhantomPositioning(
// If the position is already known and fixed, return the pos // If the position is already known and fixed, return the pos
if (positioning.type === "fixed") return positioning if (positioning.type === "fixed") return positioning
const storedPos = computedPositions.get(phantom.id)
if (storedPos) return storedPos
// If the position is to determine (positioning.type = "follows"), determine the phantom's pos // If the position is to determine (positioning.type = "follows"), determine the phantom's pos
// by calculating it from the referent position, and the action that targets the referent. // by calculating it from the referent position, and the action that targets the referent.
@ -76,7 +91,12 @@ export function computePhantomPositioning(
const referentPos = const referentPos =
referent.type === "player" referent.type === "player"
? referent.pos ? referent.pos
: computePhantomPositioning(referent, content, area) : computePhantomPositioning(
referent,
content,
computedPositions,
area,
)
// Get the origin // Get the origin
const origin = getOrigin(phantom, components) const origin = getOrigin(phantom, components)
@ -110,6 +130,7 @@ export function computePhantomPositioning(
? computePhantomPositioning( ? computePhantomPositioning(
playerBeforePhantom, playerBeforePhantom,
content, content,
computedPositions,
area, area,
) )
: playerBeforePhantom.pos : playerBeforePhantom.pos
@ -118,21 +139,29 @@ export function computePhantomPositioning(
const segment = posWithinBase(relativeTo(referentPos, pivotPoint), area) const segment = posWithinBase(relativeTo(referentPos, pivotPoint), area)
const segmentLength = norm(segment) const segmentLength = norm(segment)
const phantomDistanceFromReferent = PLAYER_RADIUS_PIXELS //TODO Place this in constants
const segmentProjection = minus(area, { const segmentProjection = minus(area, {
x: (segment.x / segmentLength) * phantomDistanceFromReferent, x: (segment.x / segmentLength) * PLAYER_RADIUS_PIXELS,
y: (segment.y / segmentLength) * phantomDistanceFromReferent, y: (segment.y / segmentLength) * PLAYER_RADIUS_PIXELS,
}) })
const segmentProjectionRatio: Pos = ratioWithinBase(segmentProjection, area) const segmentProjectionRatio: Pos = ratioWithinBase(segmentProjection, area)
return add(referentPos, segmentProjectionRatio) const result = add(referentPos, segmentProjectionRatio)
computedPositions.set(phantom.id, result)
return result
} }
export function getComponent<T extends TacticComponent>( export function getComponent<T extends TacticComponent>(
id: string, id: string,
components: TacticComponent[], components: TacticComponent[],
): T { ): T {
return components.find((c) => c.id === id)! as T return tryGetComponent<T>(id, components)!
}
export function tryGetComponent<T extends TacticComponent>(
id: string,
components: TacticComponent[],
): T | undefined {
return components.find((c) => c.id === id) as T
} }
export function areInSamePath(a: PlayerLike, b: PlayerLike) { export function areInSamePath(a: PlayerLike, b: PlayerLike) {
@ -171,8 +200,8 @@ export function isNextInPath(
export function clearPlayerPath( export function clearPlayerPath(
player: Player, player: Player,
content: TacticContent, content: StepContent,
): TacticContent { ): StepContent {
if (player.path == null) { if (player.path == null) {
return content return content
} }
@ -192,8 +221,8 @@ export function clearPlayerPath(
function removeAllPhantomsAttached( function removeAllPhantomsAttached(
to: ComponentId, to: ComponentId,
content: TacticContent, content: StepContent,
): TacticContent { ): StepContent {
let i = 0 let i = 0
while (i < content.components.length) { while (i < content.components.length) {
const component = content.components[i] const component = content.components[i]
@ -213,8 +242,8 @@ function removeAllPhantomsAttached(
export function removePlayer( export function removePlayer(
player: PlayerLike, player: PlayerLike,
content: TacticContent, content: StepContent,
): TacticContent { ): StepContent {
content = removeAllActionsTargeting(player.id, content) content = removeAllActionsTargeting(player.id, content)
content = removeAllPhantomsAttached(player.id, content) content = removeAllPhantomsAttached(player.id, content)
@ -228,7 +257,7 @@ export function removePlayer(
content.components, content.components,
)! )!
const actions = playerBefore.actions.filter( const actions = playerBefore.actions.filter(
(a) => a.target === pos.attach, (a) => a.target !== pos.attach,
) )
content = updateComponent( content = updateComponent(
{ {
@ -253,10 +282,12 @@ export function removePlayer(
const actionTarget = content.components.find( const actionTarget = content.components.find(
(c) => c.id === action.target, (c) => c.id === action.target,
)! as PlayerLike )! as PlayerLike
return spreadNewStateFromOriginStateChange( return (
spreadNewStateFromOriginStateChange(
actionTarget, actionTarget,
BallState.NONE, BallState.NONE,
content, content,
) ?? content
) )
} }
@ -266,8 +297,8 @@ export function removePlayer(
export function truncatePlayerPath( export function truncatePlayerPath(
player: Player, player: Player,
phantom: PlayerPhantom, phantom: PlayerPhantom,
content: TacticContent, content: StepContent,
): TacticContent { ): StepContent {
if (player.path == null) return content if (player.path == null) return content
const path = player.path! const path = player.path!
@ -296,11 +327,3 @@ export function truncatePlayerPath(
content, content,
) )
} }
export function changePlayerBallState(
player: PlayerLike,
newState: BallState,
content: TacticContent,
): TacticContent {
return spreadNewStateFromOriginStateChange(player, newState, content)
}

@ -0,0 +1,112 @@
import { StepInfoNode } from "../model/tactic/Tactic"
export function addStepNode(
root: StepInfoNode,
parent: StepInfoNode,
child: StepInfoNode,
): StepInfoNode {
if (root.id === parent.id) {
return {
...root,
children: root.children.concat(child),
}
}
return {
...root,
children: root.children.map((c) => addStepNode(c, parent, child)),
}
}
export function getStepName(root: StepInfoNode, step: number): string {
let ord = 1
const nodes = [root]
while (nodes.length > 0) {
const node = nodes.pop()!
if (node.id === step) break
ord++
nodes.push(...[...node.children].reverse())
}
return ord.toString()
}
export function getStepNode(
root: StepInfoNode,
stepId: number,
): StepInfoNode | undefined {
if (root.id === stepId) return root
for (const child of root.children) {
const result = getStepNode(child, stepId)
if (result) return result
}
}
export function removeStepNode(
root: StepInfoNode,
targetId: number,
): StepInfoNode | undefined {
const path = getPathTo(root, targetId)
path.reverse()
const [removedNode, ...pathToRoot] = path
let child = removedNode
for (const node of pathToRoot) {
child = {
id: node.id,
children: node.children.flatMap((c) => {
if (c.id === removedNode.id) return []
else if (c.id === child.id) {
return [child]
}
return [c]
}),
}
}
return child
}
export function getPathTo(
root: StepInfoNode,
targetId: number,
): StepInfoNode[] {
if (root.id === targetId) return [root]
for (const child of root.children) {
const subPath = getPathTo(child, targetId)
if (subPath.length > 0) return [root, ...subPath]
}
return []
}
/**
* Returns an available identifier that is not already present into the given node tree
* @param root
*/
export function getAvailableId(root: StepInfoNode): number {
const acc = (root: StepInfoNode): number =>
Math.max(root.id, ...root.children.map(acc))
return acc(root) + 1
}
export function getParent(
root: StepInfoNode,
node: StepInfoNode,
): StepInfoNode | null {
if (root.children.find((n) => n.id === node.id)) return root
for (const child of root.children) {
const result = getParent(child, node)
if (result != null) {
return result
}
}
return null
}

@ -1,9 +1,11 @@
import { Pos, ratioWithinBase } from "../geo/Pos" import { equals, Pos, ratioWithinBase } from "../geo/Pos"
import { import {
BallState, BallState,
Player, Player,
PlayerInfo, PlayerInfo,
PlayerLike, PlayerLike,
PlayerPhantom,
PlayerTeam, PlayerTeam,
} from "../model/tactic/Player" } from "../model/tactic/Player"
import { import {
@ -14,13 +16,20 @@ import {
} from "../model/tactic/CourtObjects" } from "../model/tactic/CourtObjects"
import { import {
ComponentId, ComponentId,
StepContent,
TacticComponent, TacticComponent,
TacticContent,
} from "../model/tactic/Tactic" } from "../model/tactic/Tactic"
import { overlaps } from "../geo/Box" import { overlaps } from "../geo/Box"
import { RackedCourtObject, RackedPlayer } from "./RackedItems" import { RackedCourtObject, RackedPlayer } from "./RackedItems"
import { changePlayerBallState, getComponent, getOrigin } from "./PlayerDomains" import {
getComponent,
getOrigin,
getPrecomputedPosition,
tryGetComponent,
} from "./PlayerDomains"
import { ActionKind } from "../model/tactic/Action.ts" import { ActionKind } from "../model/tactic/Action.ts"
import { spreadNewStateFromOriginStateChange } from "./ActionsDomains.ts"
export function placePlayerAt( export function placePlayerAt(
refBounds: DOMRect, refBounds: DOMRect,
@ -38,6 +47,7 @@ export function placePlayerAt(
ballState: BallState.NONE, ballState: BallState.NONE,
path: null, path: null,
actions: [], actions: [],
frozen: false,
} }
} }
@ -45,14 +55,14 @@ export function placeObjectAt(
refBounds: DOMRect, refBounds: DOMRect,
courtBounds: DOMRect, courtBounds: DOMRect,
rackedObject: RackedCourtObject, rackedObject: RackedCourtObject,
content: TacticContent, content: StepContent,
): TacticContent { ): StepContent {
const pos = ratioWithinBase(refBounds, courtBounds) const pos = ratioWithinBase(refBounds, courtBounds)
let courtObject: CourtObject let courtObject: CourtObject
switch (rackedObject.key) { switch (rackedObject.key) {
case BALL_TYPE: case BALL_TYPE: {
const playerCollidedIdx = getComponentCollided( const playerCollidedIdx = getComponentCollided(
refBounds, refBounds,
content.components, content.components,
@ -67,9 +77,10 @@ export function placeObjectAt(
id: BALL_ID, id: BALL_ID,
pos, pos,
actions: [], actions: [],
frozen: false,
} }
break break
}
default: default:
throw new Error("unknown court object " + rackedObject.key) throw new Error("unknown court object " + rackedObject.key)
} }
@ -82,9 +93,9 @@ export function placeObjectAt(
export function dropBallOnComponent( export function dropBallOnComponent(
targetedComponentIdx: number, targetedComponentIdx: number,
content: TacticContent, content: StepContent,
setAsOrigin: boolean, setAsOrigin: boolean,
): TacticContent { ): StepContent {
const component = content.components[targetedComponentIdx] const component = content.components[targetedComponentIdx]
if (component.type === "player" || component.type === "phantom") { if (component.type === "player" || component.type === "phantom") {
@ -95,13 +106,15 @@ export function dropBallOnComponent(
? BallState.HOLDS_ORIGIN ? BallState.HOLDS_ORIGIN
: BallState.HOLDS_BY_PASS : BallState.HOLDS_BY_PASS
content = changePlayerBallState(component, newState, content) content =
spreadNewStateFromOriginStateChange(component, newState, content) ??
content
} }
return removeBall(content) return removeBall(content)
} }
export function removeBall(content: TacticContent): TacticContent { export function removeBall(content: StepContent): StepContent {
const ballObjIdx = content.components.findIndex((o) => o.type == "ball") const ballObjIdx = content.components.findIndex((o) => o.type == "ball")
if (ballObjIdx == -1) { if (ballObjIdx == -1) {
@ -117,8 +130,8 @@ export function removeBall(content: TacticContent): TacticContent {
export function placeBallAt( export function placeBallAt(
refBounds: DOMRect, refBounds: DOMRect,
courtBounds: DOMRect, courtBounds: DOMRect,
content: TacticContent, content: StepContent,
): TacticContent { ): StepContent {
if (!overlaps(courtBounds, refBounds)) { if (!overlaps(courtBounds, refBounds)) {
return removeBall(content) return removeBall(content)
} }
@ -141,6 +154,7 @@ export function placeBallAt(
id: BALL_ID, id: BALL_ID,
pos, pos,
actions: [], actions: [],
frozen: false,
} }
let components = content.components let components = content.components
@ -162,9 +176,9 @@ export function moveComponent(
component: TacticComponent, component: TacticComponent,
info: PlayerInfo, info: PlayerInfo,
courtBounds: DOMRect, courtBounds: DOMRect,
content: TacticContent, content: StepContent,
removed: (content: TacticContent) => TacticContent, removed: (content: StepContent) => StepContent,
): TacticContent { ): StepContent {
const playerBounds = document const playerBounds = document
.getElementById(info.id)! .getElementById(info.id)!
.getBoundingClientRect() .getBoundingClientRect()
@ -232,8 +246,8 @@ export function moveComponent(
export function removeComponent( export function removeComponent(
componentId: ComponentId, componentId: ComponentId,
content: TacticContent, content: StepContent,
): TacticContent { ): StepContent {
return { return {
...content, ...content,
components: content.components.filter((c) => c.id !== componentId), components: content.components.filter((c) => c.id !== componentId),
@ -242,8 +256,8 @@ export function removeComponent(
export function updateComponent( export function updateComponent(
component: TacticComponent, component: TacticComponent,
content: TacticContent, content: StepContent,
): TacticContent { ): StepContent {
return { return {
...content, ...content,
components: content.components.map((c) => components: content.components.map((c) =>
@ -287,3 +301,174 @@ export function getRackPlayers(
) )
.map((key) => ({ team, key })) .map((key) => ({ team, key }))
} }
/**
* Returns a step content that only contains the terminal state of each components inside the given content
* @param content
* @param computedPositions
*/
export function computeTerminalState(
content: StepContent,
computedPositions: Map<string, Pos>,
): StepContent {
const nonPhantomComponents: (Player | CourtObject)[] =
content.components.filter(
(c): c is Exclude<TacticComponent, PlayerPhantom> =>
c.type !== "phantom",
)
const componentsTargetedState = nonPhantomComponents.map((comp) =>
comp.type === "player"
? getPlayerTerminalState(comp, content, computedPositions)
: {
...comp,
frozen: true,
},
)
return {
components: componentsTargetedState,
}
}
function getPlayerTerminalState(
player: Player,
content: StepContent,
computedPositions: Map<string, Pos>,
): Player {
function stateAfter(state: BallState): BallState {
switch (state) {
case BallState.HOLDS_ORIGIN:
return BallState.HOLDS_ORIGIN
case BallState.PASSED_ORIGIN:
case BallState.PASSED:
return BallState.NONE
case BallState.HOLDS_BY_PASS:
return BallState.HOLDS_ORIGIN
case BallState.NONE:
return BallState.NONE
}
}
function getTerminalPos(component: PlayerLike): Pos {
if (component.type === "phantom") {
const pos = getPrecomputedPosition(component, computedPositions)
if (!pos)
throw new Error(
`Attempted to get the terminal state of a step content with missing position for phantom ${component.id}`,
)
return pos
}
return component.pos
}
const phantoms = player.path?.items
if (!phantoms || phantoms.length === 0) {
const pos = getTerminalPos(player)
return {
...player,
ballState: stateAfter(player.ballState),
actions: [],
pos,
frozen: true,
}
}
const lastPhantomId = phantoms[phantoms.length - 1]
const lastPhantom = content.components.find(
(c) => c.id === lastPhantomId,
)! as PlayerPhantom
const pos = getTerminalPos(lastPhantom)
return {
type: "player",
path: { items: [] },
role: player.role,
team: player.team,
actions: [],
ballState: stateAfter(lastPhantom.ballState),
id: player.id,
pos,
frozen: true,
}
}
export function drainTerminalStateOnChildContent(
parentTerminalState: StepContent,
childContent: StepContent,
): StepContent | null {
let gotUpdated = false
for (const parentComponent of parentTerminalState.components) {
let childComponent = tryGetComponent(
parentComponent.id,
childContent.components,
)
if (!childComponent) {
//if the child does not contain the parent's component, add it to the children's content.
childContent = {
...childContent,
components: [...childContent.components, parentComponent],
}
gotUpdated = true
continue
}
// ensure that the component is a player
if (
parentComponent.type !== "player" ||
childComponent.type !== "player"
) {
continue
}
const newContentResult = spreadNewStateFromOriginStateChange(
childComponent,
parentComponent.ballState,
childContent,
)
if (newContentResult) {
gotUpdated = true
childContent = newContentResult
childComponent = getComponent<Player>(
childComponent.id,
newContentResult?.components,
)
}
// update the position of the player if it has been moved
// also force update if the child component is not frozen (the component was introduced previously by the child step but the parent added it afterward)
if (
!childComponent.frozen ||
!equals(childComponent.pos, parentComponent.pos)
) {
gotUpdated = true
childContent = updateComponent(
{
...childComponent,
frozen: true,
pos: parentComponent.pos,
},
childContent,
)
}
}
const initialChildCompsCount = childContent.components.length
//filter out all frozen components that are not present on the parent's terminal state anymore
childContent = {
components: childContent.components.filter(
(comp) =>
comp.type === "phantom" ||
!comp.frozen ||
tryGetComponent(comp.id, parentTerminalState.components),
),
}
gotUpdated ||= childContent.components.length !== initialChildCompsCount
return gotUpdated ? childContent : null
}

@ -3,6 +3,10 @@ export interface Pos {
y: number y: number
} }
export function equals(a: Pos, b: Pos): boolean {
return a.x === b.x && a.y === b.y
}
export const NULL_POS: Pos = { x: 0, y: 0 } export const NULL_POS: Pos = { x: 0, y: 0 }
/** /**

@ -1,5 +1,4 @@
import { Pos } from "../../geo/Pos" import { Pos } from "../../geo/Pos"
import { Segment } from "../../components/arrows/BendableArrow"
import { ComponentId } from "./Tactic" import { ComponentId } from "./Tactic"
export enum ActionKind { export enum ActionKind {
@ -11,6 +10,11 @@ export enum ActionKind {
export type Action = MovementAction export type Action = MovementAction
export interface Segment {
next: Pos | string
controlPoint?: Pos
}
export interface MovementAction { export interface MovementAction {
type: ActionKind type: ActionKind
target: ComponentId | Pos target: ComponentId | Pos

@ -1,10 +1,10 @@
import { Component } from "./Tactic" import { Component, Frozable } from "./Tactic"
import { Pos } from "../../geo/Pos.ts" import { Pos } from "../../geo/Pos.ts"
export const BALL_ID = "ball" export const BALL_ID = "ball"
export const BALL_TYPE = "ball" export const BALL_TYPE = "ball"
export type Ball = Component<typeof BALL_TYPE, Pos> & Frozable
//place here all different kinds of objects //place here all different kinds of objects
export type CourtObject = Ball export type CourtObject = Ball
export type Ball = Component<typeof BALL_TYPE, Pos>

@ -1,4 +1,4 @@
import { Component, ComponentId } from "./Tactic" import { Component, ComponentId, Frozable } from "./Tactic"
import { Pos } from "../../geo/Pos.ts" import { Pos } from "../../geo/Pos.ts"
export type PlayerId = string export type PlayerId = string
@ -14,7 +14,7 @@ export enum PlayerTeam {
* All information about a player * All information about a player
*/ */
export interface PlayerInfo { export interface PlayerInfo {
readonly id: string readonly id: ComponentId
/** /**
* the player's team * the player's team
* */ * */
@ -25,28 +25,20 @@ export interface PlayerInfo {
* */ * */
readonly role: string readonly role: string
/**
* True if the player has a basketball
*/
readonly ballState: BallState readonly ballState: BallState
readonly pos: Pos readonly pos: Pos
} }
export enum BallState { export enum BallState {
NONE, NONE = "NONE",
HOLDS_ORIGIN, HOLDS_ORIGIN = "HOLDS_ORIGIN",
HOLDS_BY_PASS, HOLDS_BY_PASS = "HOLDS_BY_PASS",
PASSED, PASSED = "PASSED",
PASSED_ORIGIN, PASSED_ORIGIN = "PASSED_ORIGIN",
} }
export interface Player extends Component<"player", Pos>, PlayerInfo { export interface Player extends Component<"player", Pos>, PlayerInfo, Frozable {
/**
* True if the player has a basketball
*/
readonly ballState: BallState
readonly path: MovementPath | null readonly path: MovementPath | null
} }

@ -2,21 +2,30 @@ import { Player, PlayerPhantom } from "./Player"
import { Action } from "./Action" import { Action } from "./Action"
import { CourtObject } from "./CourtObjects" import { CourtObject } from "./CourtObjects"
export type CourtType = "HALF" | "PLAIN" export interface TacticInfo {
readonly id: number
readonly name: string
readonly courtType: CourtType
readonly rootStepNode: StepInfoNode
}
export interface TacticStep {
readonly stepId: number
readonly content: StepContent
}
export interface Tactic { export interface StepContent {
id: number readonly components: TacticComponent[]
name: string
courtType: CourtType
content: TacticContent
} }
export interface TacticContent { export interface StepInfoNode {
components: TacticComponent[] readonly id: number
readonly children: StepInfoNode[]
} }
export type TacticComponent = Player | CourtObject | PlayerPhantom export type TacticComponent = Player | CourtObject | PlayerPhantom
export type ComponentId = string export type ComponentId = string
export type CourtType = "PLAIN" | "HALF"
export interface Component<T, Positioning> { export interface Component<T, Positioning> {
/** /**
@ -32,3 +41,7 @@ export interface Component<T, Positioning> {
readonly actions: Action[] readonly actions: Action[]
} }
export interface Frozable {
readonly frozen: boolean
}

@ -1,6 +1,5 @@
import { import {
CSSProperties, CSSProperties,
Dispatch,
RefObject, RefObject,
SetStateAction, SetStateAction,
useCallback, useCallback,
@ -20,12 +19,12 @@ import { Rack } from "../components/Rack"
import { PlayerPiece } from "../components/editor/PlayerPiece" import { PlayerPiece } from "../components/editor/PlayerPiece"
import { import {
ComponentId,
CourtType, CourtType,
Tactic, StepContent,
StepInfoNode,
TacticComponent, TacticComponent,
TacticContent,
} from "../model/tactic/Tactic" } from "../model/tactic/Tactic"
import { fetchAPI, fetchAPIGet } from "../Fetcher"
import SavingState, { import SavingState, {
SaveState, SaveState,
@ -36,7 +35,10 @@ import { BALL_TYPE } from "../model/tactic/CourtObjects"
import { CourtAction } from "../components/editor/CourtAction" import { CourtAction } from "../components/editor/CourtAction"
import { ActionPreview, BasketCourt } from "../components/editor/BasketCourt" import { ActionPreview, BasketCourt } from "../components/editor/BasketCourt"
import { overlaps } from "../geo/Box" import { overlaps } from "../geo/Box"
import { import {
computeTerminalState,
drainTerminalStateOnChildContent,
dropBallOnComponent, dropBallOnComponent,
getComponentCollided, getComponentCollided,
getRackPlayers, getRackPlayers,
@ -47,6 +49,7 @@ import {
removeBall, removeBall,
updateComponent, updateComponent,
} from "../editor/TacticContentDomains" } from "../editor/TacticContentDomains"
import { import {
BallState, BallState,
Player, Player,
@ -54,194 +57,261 @@ import {
PlayerLike, PlayerLike,
PlayerTeam, PlayerTeam,
} from "../model/tactic/Player" } from "../model/tactic/Player"
import { RackedCourtObject, RackedPlayer } from "../editor/RackedItems" import { RackedCourtObject, RackedPlayer } from "../editor/RackedItems"
import CourtPlayer from "../components/editor/CourtPlayer" import {
CourtPlayer,
EditableCourtPlayer,
} from "../components/editor/CourtPlayer.tsx"
import { import {
createAction, createAction,
getActionKind, getActionKind,
isActionValid, isActionValid,
removeAction, removeAction,
spreadNewStateFromOriginStateChange,
} from "../editor/ActionsDomains" } from "../editor/ActionsDomains"
import ArrowAction from "../components/actions/ArrowAction" import ArrowAction from "../components/actions/ArrowAction"
import { middlePos, Pos, ratioWithinBase } from "../geo/Pos" import { middlePos, Pos, ratioWithinBase } from "../geo/Pos"
import { Action, ActionKind } from "../model/tactic/Action" import { Action, ActionKind } from "../model/tactic/Action"
import BallAction from "../components/actions/BallAction" import BallAction from "../components/actions/BallAction"
import { import {
changePlayerBallState,
computePhantomPositioning, computePhantomPositioning,
getOrigin, getOrigin,
removePlayer, removePlayer,
} from "../editor/PlayerDomains" } from "../editor/PlayerDomains"
import { CourtBall } from "../components/editor/CourtBall" import { CourtBall } from "../components/editor/CourtBall"
import { useNavigate, useParams } from "react-router-dom" import StepsTree from "../components/editor/StepsTree"
import { DEFAULT_TACTIC_NAME } from "./NewTacticPage.tsx" import {
addStepNode,
getParent,
getStepNode,
removeStepNode,
} from "../editor/StepsDomain"
import SplitLayout from "../components/SplitLayout.tsx"
import { ServiceError, TacticService } from "../service/TacticService.ts"
import { LocalStorageTacticService } from "../service/LocalStorageTacticService.ts"
import { APITacticService } from "../service/APITacticService.ts"
import { useParams } from "react-router-dom"
const ERROR_STYLE: CSSProperties = { const ERROR_STYLE: CSSProperties = {
borderColor: "red", borderColor: "red",
} }
const GUEST_MODE_CONTENT_STORAGE_KEY = "guest_mode_content" type ComputedRelativePositions = Map<ComponentId, Pos>
const GUEST_MODE_TITLE_STORAGE_KEY = "guest_mode_title"
export interface EditorViewProps { type ComputedStepContent = {
tactic: Tactic content: StepContent
onContentChange: (tactic: TacticContent) => Promise<SaveState> relativePositions: ComputedRelativePositions
onNameChange: (name: string) => Promise<boolean>
}
interface TacticDto {
id: number
name: string
courtType: CourtType
content: string
} }
interface EditorPageProps { export interface EditorPageProps {
guestMode: boolean guestMode: boolean
} }
export default function EditorPage({ guestMode }: EditorPageProps) { export default function Editor({ guestMode }: EditorPageProps) {
const [tactic, setTactic] = useState<TacticDto | null>(() => { return <EditorPortal guestMode={guestMode} />
if (guestMode) { }
return {
id: -1,
courtType: "PLAIN",
content: '{"components": []}',
name: DEFAULT_TACTIC_NAME,
}
}
return null
})
const { tacticId: idStr } = useParams()
const id = guestMode ? -1 : parseInt(idStr!)
const navigation = useNavigate()
useEffect(() => {
if (guestMode) return
async function initialize() { interface EditorService {
const infoResponsePromise = fetchAPIGet(`tactics/${id}`) addStep(
const contentResponsePromise = fetchAPIGet(`tactics/${id}/steps/1`) parent: StepInfoNode,
content: StepContent,
): Promise<StepInfoNode | ServiceError>
const infoResponse = await infoResponsePromise removeStep(step: number): Promise<void | ServiceError>
const contentResponse = await contentResponsePromise
if (infoResponse.status == 401 || contentResponse.status == 401) { selectStep(step: number): Promise<void | ServiceError>
navigation("/login")
return
}
const { name, courtType } = await infoResponse.json() setContent(content: SetStateAction<StepContent>): void
const content = await contentResponse.text()
setTactic({ id, name, courtType, content }) setName(name: string): Promise<SaveState>
} }
initialize() function EditorPortal({ guestMode }: EditorPageProps) {
}, [guestMode, id, idStr, navigation]) const { tacticId: idStr } = useParams()
if (tactic) { if (guestMode || !idStr) {
return ( return <EditorPageWrapper service={LocalStorageTacticService.init()} />
<Editor
id={id}
courtType={tactic.courtType}
content={tactic.content}
name={tactic.name}
/>
)
} }
return <EditorLoadingScreen /> return <EditorPageWrapper service={new APITacticService(parseInt(idStr))} />
} }
function EditorLoadingScreen() { function EditorPageWrapper({ service }: { service: TacticService }) {
return <div>Loading Editor, please wait...</div> const [panicMessage, setPanicMessage] = useState<string>()
} const [stepId, setStepId] = useState<number>()
const [tacticName, setTacticName] = useState<string>()
const [courtType, setCourtType] = useState<CourtType>()
const [stepsTree, setStepsTree] = useState<StepInfoNode>()
export interface EditorProps { const courtRef = useRef<HTMLDivElement>(null)
id: number
name: string const saveContent = useCallback(
content: string async (content: StepContent) => {
courtType: CourtType const result = await service.saveContent(stepId!, content)
}
function Editor({ id, name, courtType, content }: EditorProps) { if (typeof result === "string") return SaveStates.Err
const isInGuestMode = id == -1
const storageContent = localStorage.getItem(GUEST_MODE_CONTENT_STORAGE_KEY) await updateStepContents(
const editorContent = stepId!,
isInGuestMode && storageContent != null ? storageContent : content stepsTree!,
async (id) => {
const content = await service.getContent(id)
if (typeof content === "string")
throw new Error(
"Error when retrieving children content",
)
const courtBounds =
courtRef.current!.getBoundingClientRect()
const relativePositions = computeRelativePositions(
courtBounds,
content,
)
return {
content,
relativePositions,
}
},
async (id, content) => {
const result = await service.saveContent(id, content)
if (typeof result === "string")
throw new Error("Error when updating children content")
},
)
return SaveStates.Ok
},
[service, stepId, stepsTree],
)
const storageName = localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY) const [stepContent, setStepContent, saveState] =
const editorName = isInGuestMode && storageName != null ? storageName : name useContentState<StepContent>(
{ components: [] },
SaveStates.Ok,
useMemo(() => debounceAsync(saveContent, 250), [saveContent]),
)
const navigate = useNavigate() const isNotInit = !tacticName || !stepId || !stepsTree || !courtType
return ( useEffect(() => {
<EditorView async function init() {
tactic={{ const contextResult = await service.getContext()
name: editorName, if (typeof contextResult === "string") {
id, setPanicMessage(
courtType, "There has been an error retrieving the editor initial context : " +
content: JSON.parse(editorContent), contextResult,
}}
onContentChange={async (content: TacticContent) => {
if (isInGuestMode) {
localStorage.setItem(
GUEST_MODE_CONTENT_STORAGE_KEY,
JSON.stringify(content),
) )
return SaveStates.Guest return
} }
const response = await fetchAPI( const stepId = contextResult.stepsTree.id
`tactics/${id}/steps/1`, setStepsTree(contextResult.stepsTree)
{ content }, setStepId(stepId)
"PUT", setCourtType(contextResult.courtType)
setTacticName(contextResult.name)
const contentResult = await service.getContent(stepId)
if (typeof contentResult === "string") {
setPanicMessage(
"There has been an error retrieving the tactic's root step content : " +
contentResult,
) )
if (response.status == 401) { return
navigate("/login")
} }
return response.ok ? SaveStates.Ok : SaveStates.Err setStepContent(contentResult, false)
}}
onNameChange={async (name: string) => {
if (isInGuestMode) {
localStorage.setItem(GUEST_MODE_TITLE_STORAGE_KEY, name)
return true //simulate that the name has been changed
} }
const response = await fetchAPI( if (isNotInit) init()
`tactics/${id}/name`, }, [isNotInit, service, setStepContent])
{ name },
"PUT", const editorService: EditorService = useMemo(
() => ({
async addStep(
parent: StepInfoNode,
content: StepContent,
): Promise<StepInfoNode | ServiceError> {
const result = await service.addStep(parent, content)
if (typeof result !== "string")
setStepsTree(addStepNode(stepsTree!, parent, result))
return result
},
async removeStep(step: number): Promise<void | ServiceError> {
const result = await service.removeStep(step)
if (typeof result !== "string")
setStepsTree(removeStepNode(stepsTree!, step))
return result
},
setContent(content: StepContent) {
setStepContent(content, true)
},
async setName(name: string): Promise<SaveState> {
const result = await service.setName(name)
if (typeof result === "string") return SaveStates.Err
setTacticName(name)
return SaveStates.Ok
},
async selectStep(step: number): Promise<void | ServiceError> {
const result = await service.getContent(step)
if (typeof result === "string") return result
setStepId(step)
setStepContent(result, false)
},
}),
[service, setStepContent, stepsTree],
) )
if (response.status == 401) {
navigate("/login") if (panicMessage) {
return <p>{panicMessage}</p>
} }
return response.ok
}} if (isNotInit) {
return <p>Retrieving editor context. Please wait...</p>
}
return (
<EditorPage
name={tacticName}
courtType={courtType}
stepId={stepId}
stepsTree={stepsTree}
contentSaveState={saveState}
content={stepContent}
service={editorService}
courtRef={courtRef}
/> />
) )
} }
function EditorView({ export interface EditorViewProps {
tactic: { id, name, content: initialContent, courtType }, stepsTree: StepInfoNode
onContentChange, name: string
onNameChange, courtType: CourtType
}: EditorViewProps) { content: StepContent
const isInGuestMode = id == -1 contentSaveState: SaveState
stepId: number
courtRef: RefObject<HTMLDivElement>
service: EditorService
}
function EditorPage({
name,
courtType,
content,
stepId,
contentSaveState,
stepsTree,
courtRef,
service,
}: EditorViewProps) {
const [titleStyle, setTitleStyle] = useState<CSSProperties>({}) const [titleStyle, setTitleStyle] = useState<CSSProperties>({})
const [content, setContent, saveState] = useContentState(
initialContent,
isInGuestMode ? SaveStates.Guest : SaveStates.Ok,
useMemo(() => debounceAsync(onContentChange), [onContentChange]),
)
const [allies, setAllies] = useState(() => const allies = getRackPlayers(PlayerTeam.Allies, content.components)
getRackPlayers(PlayerTeam.Allies, content.components), const opponents = getRackPlayers(PlayerTeam.Opponents, content.components)
)
const [opponents, setOpponents] = useState(() =>
getRackPlayers(PlayerTeam.Opponents, content.components),
)
const [objects, setObjects] = useState<RackedCourtObject[]>(() => const [objects, setObjects] = useState<RackedCourtObject[]>(() =>
isBallOnCourt(content) ? [] : [{ key: "ball" }], isBallOnCourt(content) ? [] : [{ key: "ball" }],
@ -251,64 +321,59 @@ function EditorView({
null, null,
) )
const courtRef = useRef<HTMLDivElement>(null) const [isStepsTreeVisible, setStepsTreeVisible] = useState(false)
const courtBounds = useCallback(
() => courtRef.current!.getBoundingClientRect(),
[courtRef],
)
const [editorContentCurtainWidth, setEditorContentCurtainWidth] =
useState(80)
const relativePositions = useMemo(() => {
const courtBounds = courtRef.current?.getBoundingClientRect()
return courtBounds
? computeRelativePositions(courtBounds, content)
: new Map()
}, [content, courtRef])
const setComponents = (action: SetStateAction<TacticComponent[]>) => { const setComponents = (action: SetStateAction<TacticComponent[]>) => {
setContent((c) => ({ service.setContent((c) => ({
...c, ...c,
components: components:
typeof action == "function" ? action(c.components) : action, typeof action == "function" ? action(c.components) : action,
})) }))
} }
const courtBounds = useCallback(
() => courtRef.current!.getBoundingClientRect(),
[courtRef],
)
useEffect(() => { useEffect(() => {
setObjects(isBallOnCourt(content) ? [] : [{ key: "ball" }]) setObjects(isBallOnCourt(content) ? [] : [{ key: "ball" }])
}, [setObjects, content]) }, [setObjects, content])
const insertRackedPlayer = (player: Player) => { const insertRackedPlayer = (player: Player) => {
let setter
switch (player.team) {
case PlayerTeam.Opponents:
setter = setOpponents
break
case PlayerTeam.Allies:
setter = setAllies
}
if (player.ballState == BallState.HOLDS_BY_PASS) { if (player.ballState == BallState.HOLDS_BY_PASS) {
setObjects([{ key: "ball" }]) setObjects([{ key: "ball" }])
} }
setter((players) => [
...players,
{
team: player.team,
pos: player.role,
key: player.role,
},
])
} }
const doRemovePlayer = useCallback( const doRemovePlayer = useCallback(
(component: PlayerLike) => { (component: PlayerLike) => {
setContent((c) => removePlayer(component, c)) service.setContent((c) => removePlayer(component, c))
if (component.type == "player") insertRackedPlayer(component) if (component.type == "player") insertRackedPlayer(component)
}, },
[setContent], [service],
) )
const doMoveBall = useCallback( const doMoveBall = useCallback(
(newBounds: DOMRect, from?: PlayerLike) => { (newBounds: DOMRect, from?: PlayerLike) => {
setContent((content) => { service.setContent((content) => {
if (from) { if (from) {
content = changePlayerBallState( content =
spreadNewStateFromOriginStateChange(
from, from,
BallState.NONE, BallState.NONE,
content, content,
) ) ?? content
} }
content = placeBallAt(newBounds, courtBounds(), content) content = placeBallAt(newBounds, courtBounds(), content)
@ -316,12 +381,12 @@ function EditorView({
return content return content
}) })
}, },
[courtBounds, setContent], [courtBounds, service],
) )
const validatePlayerPosition = useCallback( const validatePlayerPosition = useCallback(
(player: PlayerLike, info: PlayerInfo, newPos: Pos) => { (player: PlayerLike, info: PlayerInfo, newPos: Pos) => {
setContent((content) => service.setContent((content) =>
moveComponent( moveComponent(
newPos, newPos,
player, player,
@ -336,12 +401,13 @@ function EditorView({
), ),
) )
}, },
[courtBounds, setContent], [courtBounds, service],
) )
const renderAvailablePlayerActions = useCallback( const renderAvailablePlayerActions = useCallback(
(info: PlayerInfo, player: PlayerLike) => { (info: PlayerInfo, player: PlayerLike) => {
let canPlaceArrows: boolean let canPlaceArrows: boolean
let isFrozen: boolean = false
if (player.type == "player") { if (player.type == "player") {
canPlaceArrows = canPlaceArrows =
@ -349,6 +415,7 @@ function EditorView({
player.actions.findIndex( player.actions.findIndex(
(p) => p.type != ActionKind.SHOOT, (p) => p.type != ActionKind.SHOOT,
) == -1 ) == -1
isFrozen = player.frozen
} else { } else {
const origin = getOrigin(player, content.components) const origin = getOrigin(player, content.components)
const path = origin.path! const path = origin.path!
@ -376,9 +443,10 @@ function EditorView({
playerInfo={info} playerInfo={info}
content={content} content={content}
courtRef={courtRef} courtRef={courtRef}
setContent={setContent} setContent={service.setContent}
/> />
), ),
!isFrozen &&
(info.ballState === BallState.HOLDS_ORIGIN || (info.ballState === BallState.HOLDS_ORIGIN ||
info.ballState === BallState.PASSED_ORIGIN) && ( info.ballState === BallState.PASSED_ORIGIN) && (
<BallAction <BallAction
@ -390,7 +458,13 @@ function EditorView({
), ),
] ]
}, },
[content, doMoveBall, previewAction?.isInvalid, setContent], [
content,
courtRef,
doMoveBall,
previewAction?.isInvalid,
service.setContent,
],
) )
const renderPlayer = useCallback( const renderPlayer = useCallback(
@ -406,16 +480,30 @@ function EditorView({
pos: computePhantomPositioning( pos: computePhantomPositioning(
component, component,
content, content,
relativePositions,
courtBounds(), courtBounds(),
), ),
ballState: component.ballState, ballState: component.ballState,
} }
} else { } else {
info = component info = component
}
if (component.frozen) {
return ( return (
<CourtPlayer <CourtPlayer
key={component.id}
playerInfo={info}
className={"player"}
availableActions={() =>
renderAvailablePlayerActions(info, component)
}
/>
)
}
}
return (
<EditableCourtPlayer
key={component.id} key={component.id}
className={isPhantom ? "phantom" : "player"} className={isPhantom ? "phantom" : "player"}
playerInfo={info} playerInfo={info}
@ -431,23 +519,26 @@ function EditorView({
) )
}, },
[ [
content.components, courtRef,
doRemovePlayer, content,
relativePositions,
courtBounds,
renderAvailablePlayerActions, renderAvailablePlayerActions,
validatePlayerPosition, validatePlayerPosition,
doRemovePlayer,
], ],
) )
const doDeleteAction = useCallback( const doDeleteAction = useCallback(
(_: Action, idx: number, origin: TacticComponent) => { (_: Action, idx: number, origin: TacticComponent) => {
setContent((content) => removeAction(origin, idx, content)) service.setContent((content) => removeAction(origin, idx, content))
}, },
[setContent], [service],
) )
const doUpdateAction = useCallback( const doUpdateAction = useCallback(
(component: TacticComponent, action: Action, actionIndex: number) => { (component: TacticComponent, action: Action, actionIndex: number) => {
setContent((content) => service.setContent((content) =>
updateComponent( updateComponent(
{ {
...component, ...component,
@ -461,22 +552,22 @@ function EditorView({
), ),
) )
}, },
[setContent], [service],
) )
const renderComponent = useCallback( const renderComponent = useCallback(
(component: TacticComponent) => { (component: TacticComponent) => {
if (component.type == "player" || component.type == "phantom") { if (component.type === "player" || component.type === "phantom") {
return renderPlayer(component) return renderPlayer(component)
} }
if (component.type == BALL_TYPE) { if (component.type === BALL_TYPE) {
return ( return (
<CourtBall <CourtBall
key="ball" key="ball"
ball={component} ball={component}
onPosValidated={doMoveBall} onPosValidated={doMoveBall}
onRemove={() => { onRemove={() => {
setContent((content) => removeBall(content)) service.setContent((content) => removeBall(content))
setObjects((objects) => [ setObjects((objects) => [
...objects, ...objects,
{ key: "ball" }, { key: "ball" },
@ -487,7 +578,7 @@ function EditorView({
} }
throw new Error("unknown tactic component " + component) throw new Error("unknown tactic component " + component)
}, },
[renderPlayer, doMoveBall, setContent], [service, renderPlayer, doMoveBall],
) )
const renderActions = useCallback( const renderActions = useCallback(
@ -509,37 +600,15 @@ function EditorView({
/> />
) )
}), }),
[doDeleteAction, doUpdateAction], [courtRef, doDeleteAction, doUpdateAction],
) )
return ( const contentNode = (
<div id="main-div"> <div id="content-div">
<div id="topbar-div">
<div id="topbar-left">
<SavingState state={saveState} />
</div>
<div id="title-input-div">
<TitleInput
style={titleStyle}
default_value={name}
onValidated={useCallback(
(new_name) => {
onNameChange(new_name).then((success) => {
setTitleStyle(success ? {} : ERROR_STYLE)
})
},
[onNameChange],
)}
/>
</div>
<div id="topbar-right" />
</div>
<div id="edit-div">
<div id="racks"> <div id="racks">
<PlayerRack <PlayerRack
id={"allies"} id={"allies"}
objects={allies} objects={allies}
setObjects={setAllies}
setComponents={setComponents} setComponents={setComponents}
courtRef={courtRef} courtRef={courtRef}
/> />
@ -558,7 +627,7 @@ function EditorView({
)} )}
onElementDetached={useCallback( onElementDetached={useCallback(
(r, e: RackedCourtObject) => (r, e: RackedCourtObject) =>
setContent((content) => service.setContent((content) =>
placeObjectAt( placeObjectAt(
r.getBoundingClientRect(), r.getBoundingClientRect(),
courtBounds(), courtBounds(),
@ -566,7 +635,7 @@ function EditorView({
content, content,
), ),
), ),
[courtBounds, setContent], [courtBounds, service],
)} )}
render={renderCourtObject} render={renderCourtObject}
/> />
@ -574,7 +643,6 @@ function EditorView({
<PlayerRack <PlayerRack
id={"opponents"} id={"opponents"}
objects={opponents} objects={opponents}
setObjects={setOpponents}
setComponents={setComponents} setComponents={setComponents}
courtRef={courtRef} courtRef={courtRef}
/> />
@ -592,6 +660,112 @@ function EditorView({
</div> </div>
</div> </div>
</div> </div>
)
const stepsTreeNode = (
<EditorStepsTree
selectedStepId={stepId}
root={stepsTree}
onAddChildren={useCallback(
async (parent) => {
const addedNode = await service.addStep(
parent,
computeTerminalState(content, relativePositions),
)
if (typeof addedNode === "string") {
console.error("could not add step : " + addedNode)
return
}
await service.selectStep(addedNode.id)
},
[service, content, relativePositions],
)}
onRemoveNode={useCallback(
async (removed) => {
await service.removeStep(removed.id)
await service.selectStep(getParent(stepsTree, removed)!.id)
},
[service, stepsTree],
)}
onStepSelected={useCallback(
(node) => service.selectStep(node.id),
[service],
)}
/>
)
return (
<div id="main-div">
<div id="topbar-div">
<div id="topbar-left">
<SavingState state={contentSaveState} />
</div>
<div id="title-input-div">
<TitleInput
style={titleStyle}
default_value={name}
onValidated={useCallback(
(new_name) => {
service.setName(new_name).then((state) => {
setTitleStyle(
state == SaveStates.Ok
? {}
: ERROR_STYLE,
)
})
},
[service],
)}
/>
</div>
<div id="topbar-right">
<button
id={"show-steps-button"}
onClick={() => setStepsTreeVisible((b) => !b)}>
ETAPES
</button>
</div>
</div>
<div id="editor-div">
{isStepsTreeVisible ? (
<SplitLayout
rightWidth={editorContentCurtainWidth}
onRightWidthChange={setEditorContentCurtainWidth}>
{contentNode}
{stepsTreeNode}
</SplitLayout>
) : (
contentNode
)}
</div>
</div>
)
}
interface EditorStepsTreeProps {
selectedStepId: number
root: StepInfoNode
onAddChildren: (parent: StepInfoNode) => void
onRemoveNode: (node: StepInfoNode) => void
onStepSelected: (node: StepInfoNode) => void
}
function EditorStepsTree({
selectedStepId,
root,
onAddChildren,
onRemoveNode,
onStepSelected,
}: EditorStepsTreeProps) {
return (
<div id="steps-div">
<StepsTree
root={root}
selectedStepId={selectedStepId}
onStepSelected={onStepSelected}
onAddChildren={onAddChildren}
onRemoveNode={onRemoveNode}
/>
</div> </div>
) )
} }
@ -599,7 +773,7 @@ function EditorView({
interface PlayerRackProps { interface PlayerRackProps {
id: string id: string
objects: RackedPlayer[] objects: RackedPlayer[]
setObjects: (state: RackedPlayer[]) => void setObjects?: (state: RackedPlayer[]) => void
setComponents: ( setComponents: (
f: (components: TacticComponent[]) => TacticComponent[], f: (components: TacticComponent[]) => TacticComponent[],
) => void ) => void
@ -659,8 +833,8 @@ interface CourtPlayerArrowActionProps {
player: PlayerLike player: PlayerLike
isInvalid: boolean isInvalid: boolean
content: TacticContent content: StepContent
setContent: (state: SetStateAction<TacticContent>) => void setContent: (state: SetStateAction<StepContent>) => void
setPreviewAction: (state: SetStateAction<ActionPreview | null>) => void setPreviewAction: (state: SetStateAction<ActionPreview | null>) => void
courtRef: RefObject<HTMLDivElement> courtRef: RefObject<HTMLDivElement>
} }
@ -768,7 +942,7 @@ function CourtPlayerArrowAction({
) )
} }
function isBallOnCourt(content: TacticContent) { function isBallOnCourt(content: StepContent) {
return ( return (
content.components.findIndex( content.components.findIndex(
(c) => (c) =>
@ -815,30 +989,91 @@ function debounceAsync<A, B>(
function useContentState<S>( function useContentState<S>(
initialContent: S, initialContent: S,
initialSaveState: SaveState, initialSaveState: SaveState,
saveStateCallback: (s: S) => Promise<SaveState>, applyStateCallback: (content: S) => Promise<SaveState>,
): [S, Dispatch<SetStateAction<S>>, SaveState] { ): [S, (newState: SetStateAction<S>, runCallback: boolean) => void, SaveState] {
const [content, setContent] = useState(initialContent) const [content, setContent] = useState(initialContent)
const [savingState, setSavingState] = useState(initialSaveState) const [savingState, setSavingState] = useState(initialSaveState)
const setContentSynced = useCallback( const setContentSynced = useCallback(
(newState: SetStateAction<S>) => { (newState: SetStateAction<S>, callSaveCallback: boolean) => {
setContent((content) => { setContent((content) => {
const state = const state =
typeof newState === "function" typeof newState === "function"
? (newState as (state: S) => S)(content) ? (newState as (state: S) => S)(content)
: newState : newState
if (state !== content) { if (state !== content && callSaveCallback) {
setSavingState(SaveStates.Saving) setSavingState(SaveStates.Saving)
saveStateCallback(state) applyStateCallback(state)
.then(setSavingState) .then(setSavingState)
.catch(() => setSavingState(SaveStates.Err)) .catch((e) => {
setSavingState(SaveStates.Err)
console.error(e)
})
} }
return state return state
}) })
}, },
[saveStateCallback], [applyStateCallback],
) )
return [content, setContentSynced, savingState] return [content, setContentSynced, savingState]
} }
function computeRelativePositions(courtBounds: DOMRect, content: StepContent) {
const relativePositionsCache: ComputedRelativePositions = new Map()
for (const component of content.components) {
if (component.type !== "phantom") continue
computePhantomPositioning(
component,
content,
relativePositionsCache,
courtBounds,
)
}
return relativePositionsCache
}
async function updateStepContents(
stepId: number,
stepsTree: StepInfoNode,
getStepContent: (stepId: number) => Promise<ComputedStepContent>,
setStepContent: (stepId: number, content: StepContent) => Promise<void>,
) {
async function updateSteps(
step: StepInfoNode,
content: StepContent,
relativePositions: ComputedRelativePositions,
) {
const terminalStateContent = computeTerminalState(
content,
relativePositions,
)
for (const child of step.children) {
const {
content: childContent,
relativePositions: childRelativePositions,
} = await getStepContent(child.id)
const childUpdatedContent = drainTerminalStateOnChildContent(
terminalStateContent,
childContent,
)
if (childUpdatedContent) {
await setStepContent(child.id, childUpdatedContent)
await updateSteps(
child,
childUpdatedContent,
childRelativePositions,
)
}
}
}
const { content, relativePositions } = await getStepContent(stepId)
const startNode = getStepNode(stepsTree!, stepId)!
await updateSteps(startNode, content, relativePositions)
}

@ -151,7 +151,7 @@ function TitlePersonalSpace() {
function TableData({ allTactics }: { allTactics: Tactic[] }) { function TableData({ allTactics }: { allTactics: Tactic[] }) {
const nbRow = Math.floor(allTactics.length / 3) + 1 const nbRow = Math.floor(allTactics.length / 3) + 1
let listTactic = Array(nbRow) const listTactic = Array(nbRow)
for (let i = 0; i < nbRow; i++) { for (let i = 0; i < nbRow; i++) {
listTactic[i] = Array(0) listTactic[i] = Array(0)
} }

@ -66,7 +66,10 @@ function CourtKindButton({
) )
if (response.status === 401) { if (response.status === 401) {
saveSession({...getSession(), urlTarget: location.pathname}) saveSession({
...getSession(),
urlTarget: location.pathname,
})
// if unauthorized // if unauthorized
navigate("/login") navigate("/login")
return return

@ -0,0 +1,84 @@
import { TacticService, ServiceError, TacticContext } from "./TacticService.ts"
import { StepContent, StepInfoNode } from "../model/tactic/Tactic.ts"
import { fetchAPI, fetchAPIGet } from "../Fetcher.ts"
export class APITacticService implements TacticService {
private readonly tacticId: number
constructor(tacticId: number) {
this.tacticId = tacticId
}
async getContext(): Promise<TacticContext | ServiceError> {
const infoResponsePromise = fetchAPIGet(`tactics/${this.tacticId}`)
const treeResponsePromise = fetchAPIGet(`tactics/${this.tacticId}/tree`)
const infoResponse = await infoResponsePromise
const treeResponse = await treeResponsePromise
if (infoResponse.status == 401 || treeResponse.status == 401) {
return ServiceError.UNAUTHORIZED
}
const { name, courtType } = await infoResponse.json()
const { root } = await treeResponse.json()
return { courtType, name, stepsTree: root }
}
async addStep(
parent: StepInfoNode,
content: StepContent,
): Promise<StepInfoNode | ServiceError> {
const response = await fetchAPI(`tactics/${this.tacticId}/steps`, {
parentId: parent.id,
content,
})
if (response.status == 404) return ServiceError.NOT_FOUND
if (response.status == 401) return ServiceError.UNAUTHORIZED
const { stepId } = await response.json()
return { id: stepId, children: [] }
}
async removeStep(id: number): Promise<void | ServiceError> {
const response = await fetchAPI(
`tactics/${this.tacticId}/steps/${id}`,
{},
"DELETE",
)
if (response.status == 404) return ServiceError.NOT_FOUND
if (response.status == 401) return ServiceError.UNAUTHORIZED
}
async setName(name: string): Promise<void | ServiceError> {
const response = await fetchAPI(
`tactics/${this.tacticId}/name`,
{ name },
"PUT",
)
if (response.status == 404) return ServiceError.NOT_FOUND
if (response.status == 401) return ServiceError.UNAUTHORIZED
}
async saveContent(
step: number,
content: StepContent,
): Promise<void | ServiceError> {
const response = await fetchAPI(
`tactics/${this.tacticId}/steps/${step}`,
{ content },
"PUT",
)
if (response.status == 404) return ServiceError.NOT_FOUND
if (response.status == 401) return ServiceError.UNAUTHORIZED
}
async getContent(step: number): Promise<StepContent | ServiceError> {
const response = await fetchAPIGet(
`tactics/${this.tacticId}/steps/${step}`,
)
if (response.status == 404) return ServiceError.NOT_FOUND
if (response.status == 401) return ServiceError.UNAUTHORIZED
return await response.json()
}
}

@ -0,0 +1,99 @@
import { TacticService, ServiceError, TacticContext } from "./TacticService.ts"
import { StepContent, StepInfoNode } from "../model/tactic/Tactic.ts"
import {
addStepNode,
getAvailableId,
removeStepNode,
} from "../editor/StepsDomain.ts"
const GUEST_MODE_STEP_CONTENT_STORAGE_KEY = "guest_mode_step"
const GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY = "guest_mode_step_tree"
const GUEST_MODE_TITLE_STORAGE_KEY = "guest_mode_title"
export class LocalStorageTacticService implements TacticService {
private constructor() {}
static init(): LocalStorageTacticService {
const root = localStorage.getItem(
GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY,
)
if (root === null) {
localStorage.setItem(
GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY,
JSON.stringify(<StepInfoNode>{ id: 1, children: [] }),
)
}
return new LocalStorageTacticService()
}
async getContext(): Promise<TacticContext | ServiceError> {
const stepsTree: StepInfoNode = JSON.parse(
localStorage.getItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY)!,
)
const name =
localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY) ??
"Nouvelle Tactique"
return {
stepsTree,
name,
courtType: "PLAIN",
}
}
async addStep(
parent: StepInfoNode,
content: StepContent,
): Promise<StepInfoNode | ServiceError> {
const root: StepInfoNode = JSON.parse(
localStorage.getItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY)!,
)
const nodeId = getAvailableId(root)
const node = { id: nodeId, children: [] }
const resultTree = addStepNode(root, parent, node)
localStorage.setItem(
GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY,
JSON.stringify(resultTree),
)
localStorage.setItem(
GUEST_MODE_STEP_CONTENT_STORAGE_KEY + node.id,
JSON.stringify(content),
)
return node
}
async getContent(step: number): Promise<StepContent | ServiceError> {
const content = localStorage.getItem(
GUEST_MODE_STEP_CONTENT_STORAGE_KEY + step,
)
return content ? JSON.parse(content) : null
}
async removeStep(id: number): Promise<void | ServiceError> {
const root: StepInfoNode = JSON.parse(
localStorage.getItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY)!,
)
localStorage.setItem(
GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY,
JSON.stringify(removeStepNode(root, id)),
)
}
async saveContent(
step: number,
content: StepContent,
): Promise<void | ServiceError> {
localStorage.setItem(
GUEST_MODE_STEP_CONTENT_STORAGE_KEY + step,
JSON.stringify(content),
)
}
async setName(name: string): Promise<void | ServiceError> {
localStorage.setItem(GUEST_MODE_TITLE_STORAGE_KEY, name)
}
}

@ -0,0 +1,32 @@
import { CourtType, StepContent, StepInfoNode } from "../model/tactic/Tactic.ts"
export interface TacticContext {
stepsTree: StepInfoNode
name: string
courtType: CourtType
}
export enum ServiceError {
UNAUTHORIZED = "UNAUTHORIZED",
NOT_FOUND = "NOT_FOUND",
}
export interface TacticService {
getContext(): Promise<TacticContext | ServiceError>
addStep(
parent: StepInfoNode,
content: StepContent,
): Promise<StepInfoNode | ServiceError>
removeStep(id: number): Promise<void | ServiceError>
setName(name: string): Promise<void | ServiceError>
saveContent(
step: number,
content: StepContent,
): Promise<void | ServiceError>
getContent(step: number): Promise<StepContent | ServiceError>
}

@ -26,13 +26,13 @@
width: 100%; width: 100%;
display: flex; display: flex;
background-color: var(--main-color); background-color: var(--main-color);
margin-bottom: 3px;
justify-content: space-between; justify-content: space-between;
align-items: stretch; align-items: stretch;
} }
#racks { #racks {
margin: 3px 6px 0 6px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@ -44,8 +44,29 @@
align-self: center; align-self: center;
} }
#edit-div { #editor-div {
display: flex;
flex-direction: row;
}
#content-div,
#editor-div,
#steps-div {
height: 100%; height: 100%;
width: 100%;
}
#content-div {
overflow: hidden;
}
.curtain {
width: 100%;
}
#steps-div {
background-color: var(--editor-tree-background);
overflow: scroll;
} }
#allies-rack, #allies-rack,
@ -112,7 +133,6 @@
height: 100%; height: 100%;
width: 100%; width: 100%;
user-select: none; user-select: none;
-webkit-user-drag: none;
} }
#court-image * { #court-image * {
@ -130,6 +150,11 @@
font-family: monospace; font-family: monospace;
} }
.save-state,
#show-steps-button {
user-select: none;
}
.save-state-error { .save-state-error {
color: red; color: red;
} }

@ -0,0 +1,91 @@
.step-piece {
position: relative;
font-family: monospace;
pointer-events: all;
background-color: var(--editor-tree-step-piece);
color: var(--selected-team-secondarycolor);
border-radius: 100px;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
cursor: pointer;
border: 2px solid var(--editor-tree-background);
}
.step-piece-selected {
border: 2px solid var(--selection-color-light);
}
.step-piece-selected,
.step-piece:focus,
.step-piece:hover {
background-color: var(--editor-tree-step-piece-hovered);
}
.step-piece-selected .step-piece-actions,
.step-piece:hover .step-piece-actions,
.step-piece:focus-within .step-piece-actions {
visibility: visible;
}
.step-piece-actions {
visibility: hidden;
display: flex;
position: absolute;
column-gap: 5px;
top: -140%;
}
.add-icon,
.remove-icon {
background-color: white;
border-radius: 100%;
}
.add-icon {
fill: var(--add-icon-fill);
}
.remove-icon {
fill: var(--remove-icon-fill);
}
.step-children {
margin-top: 10vh;
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
}
.step-group {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
top: 0;
width: 100%;
height: 100%;
}
.steps-tree {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding-top: 10%;
height: 100%;
}

@ -13,6 +13,7 @@
--buttons-shadow-color: #a8a8a8; --buttons-shadow-color: #a8a8a8;
--selection-color: #3f7fc4; --selection-color: #3f7fc4;
--selection-color-light: #acd8f8;
--border-color: #ffffff; --border-color: #ffffff;
@ -29,4 +30,10 @@
--main-contrast-color: #e6edf3; --main-contrast-color: #e6edf3;
--font-title: Helvetica; --font-title: Helvetica;
--font-content: Helvetica; --font-content: Helvetica;
--editor-tree-background: #503636;
--editor-tree-step-piece: #0bd9d9;
--editor-tree-step-piece-hovered: #ea9b9b;
--add-icon-fill: #00a206;
--remove-icon-fill: #e50046;
} }

@ -8,6 +8,9 @@ export default defineConfig({
build: { build: {
target: "es2021", target: "es2021",
}, },
test: {
environment: "jsdom",
},
plugins: [ plugins: [
react(), react(),
cssInjectedByJsPlugin({ cssInjectedByJsPlugin({

Loading…
Cancel
Save