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

Reviewed-on: #114
Maxime BATISTA 1 year ago
commit 4f6b905f22

@ -1,4 +1,5 @@
set -e
#!/usr/bin/env bash
set -xeu
export OUTPUT=$1
export BASE=$2
@ -10,9 +11,9 @@ echo "VITE_BASE=$BASE" >> .env.PROD
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
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/react": "^13.4.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-dom": "^18.2.14",
"eslint-plugin-react-refresh": "^0.4.5",
@ -23,7 +21,7 @@
"scripts": {
"start": "vite --host",
"build": "vite build",
"test": "vite test",
"test": "vitest",
"format": "prettier --config .prettierrc '.' --write",
"tsc": "tsc"
},
@ -34,8 +32,10 @@
"eslint": "^8.53.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"jsdom": "^24.0.0",
"prettier": "^3.1.0",
"typescript": "^5.2.2",
"vite-plugin-svgr": "^4.1.0"
"vite-plugin-svgr": "^4.1.0",
"vitest": "^1.3.1"
}
}

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

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

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 }> {
id: string
objects: E[]
onChange: (objects: E[]) => void
onChange?: (objects: E[]) => void
canDetach: (ref: HTMLDivElement) => boolean
onElementDetached: (ref: HTMLDivElement, el: E) => void
render: (e: E) => ReactElement
@ -44,7 +44,7 @@ export function Rack<E extends { key: string | number }>({
const index = objects.findIndex(
(o) => o.key === element.key,
)
onChange(objects.toSpliced(index, 1))
if (onChange) onChange(objects.toSpliced(index, 1))
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 Draggable from "react-draggable"
import { Segment } from "../../model/tactic/Action.ts"
export interface BendableArrowProps {
area: RefObject<HTMLElement>
@ -57,11 +58,6 @@ const ArrowStyleDefaults: ArrowStyle = {
color: "black",
}
export interface Segment {
next: Pos | string
controlPoint?: Pos
}
/**
* Given a circle shaped by a central position, and a radius, return
* a position that is constrained on its perimeter, pointing to the direction
@ -389,6 +385,7 @@ export default function BendableArrow({
useEffect(() => {
const observer = new MutationObserver(update)
const config = { attributes: true }
if (typeof startPos == "string") {
observer.observe(document.getElementById(startPos)!, config)
}
@ -402,6 +399,14 @@ export default function BendableArrow({
return () => observer.disconnect()
}, [startPos, segments, update])
useEffect(() => {
const observer = new ResizeObserver(update)
observer.observe(area.current!, {})
return () => observer.disconnect()
})
// Adds a selection handler
// Also force an update when the window is resized
useEffect(() => {

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

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

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

@ -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 Draggable from "react-draggable"
import { PlayerPiece } from "./PlayerPiece"
@ -8,38 +14,53 @@ import { NULL_POS, Pos, ratioWithinBase } from "../../geo/Pos"
export interface CourtPlayerProps {
playerInfo: PlayerInfo
className?: string
availableActions: (ro: HTMLElement) => ReactNode[]
}
export interface EditableCourtPlayerProps extends CourtPlayerProps {
courtRef: RefObject<HTMLElement>
onPositionValidated: (newPos: Pos) => void
onRemove: () => void
courtRef: RefObject<HTMLElement>
availableActions: (ro: HTMLElement) => ReactNode[]
}
const MOVE_AREA_SENSIBILITY = 0.001
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
* */
export default function CourtPlayer({
export function EditableCourtPlayer({
playerInfo,
className,
courtRef,
onPositionValidated,
onRemove,
courtRef,
availableActions,
}: CourtPlayerProps) {
const usesBall = playerInfo.ballState != BallState.NONE
const { x, y } = playerInfo.pos
}: EditableCourtPlayerProps) {
const pieceRef = useRef<HTMLDivElement>(null)
const { x, y } = playerInfo.pos
return (
<Draggable
handle=".player-piece"
nodeRef={pieceRef}
//The piece is positioned using top/bottom style attributes instead
position={NULL_POS}
onStop={useCallback(() => {
const pieceBounds = pieceRef.current!.getBoundingClientRect()
@ -50,30 +71,58 @@ export default function CourtPlayer({
if (
Math.abs(pos.x - x) >= MOVE_AREA_SENSIBILITY ||
Math.abs(pos.y - y) >= MOVE_AREA_SENSIBILITY
)
) {
onPositionValidated(pos)
}
}, [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
id={playerInfo.id}
ref={pieceRef}
id={playerInfo.id}
className={"player " + (className ?? "")}
style={{
position: "absolute",
left: `${x * 100}%`,
top: `${y * 100}%`,
}}>
<div
tabIndex={0}
className="player-content"
onKeyUp={useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key == "Delete") onRemove()
},
[onRemove],
)}>
<div className="player-actions">
{availableActions(pieceRef.current!)}
</div>
<div tabIndex={0} className="player-content" onKeyUp={onKeyUp}>
{availableActions && (
<div className="player-actions">{availableActions()}</div>
)}
<PlayerPiece
team={playerInfo.team}
text={playerInfo.role}
@ -81,6 +130,5 @@ export default function CourtPlayer({
/>
</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 {
ComponentId,
StepContent,
TacticComponent,
TacticContent,
} from "../model/tactic/Tactic"
import { overlaps } from "../geo/Box"
import { Action, ActionKind, moves } from "../model/tactic/Action"
import { removeBall, updateComponent } from "./TacticContentDomains"
import {
areInSamePath,
changePlayerBallState,
getComponent,
getOrigin,
getPlayerNextTo,
@ -212,8 +211,8 @@ export function createAction(
origin: PlayerLike,
courtBounds: DOMRect,
arrowHead: DOMRect,
content: TacticContent,
): { createdAction: Action; newContent: TacticContent } {
content: StepContent,
): { createdAction: Action; newContent: StepContent } {
/**
* Creates a new phantom component.
* Be aware that this function will reassign the `content` parameter.
@ -371,8 +370,8 @@ export function createAction(
export function removeAllActionsTargeting(
componentId: ComponentId,
content: TacticContent,
): TacticContent {
content: StepContent,
): StepContent {
const components = []
for (let i = 0; i < content.components.length; i++) {
const component = content.components[i]
@ -391,9 +390,10 @@ export function removeAllActionsTargeting(
export function removeAction(
origin: TacticComponent,
actionIdx: number,
content: TacticContent,
): TacticContent {
content: StepContent,
): StepContent {
const action = origin.actions[actionIdx]
origin = {
...origin,
actions: origin.actions.toSpliced(actionIdx, 1),
@ -410,20 +410,27 @@ export function removeAction(
(origin.type === "player" || origin.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) {
content = changePlayerBallState(
content =
spreadNewStateFromOriginStateChange(
origin,
BallState.HOLDS_BY_PASS,
content,
)
) ?? content
} else if (origin.ballState === BallState.PASSED_ORIGIN) {
content = changePlayerBallState(
content =
spreadNewStateFromOriginStateChange(
origin,
BallState.HOLDS_ORIGIN,
content,
)
) ?? content
}
}
@ -458,14 +465,15 @@ export function removeAction(
* @param origin
* @param newState
* @param content
* @returns the new state if it has been updated, or null if no changes were operated
*/
export function spreadNewStateFromOriginStateChange(
origin: PlayerLike,
newState: BallState,
content: TacticContent,
): TacticContent {
content: StepContent,
): StepContent | null {
if (origin.ballState === newState) {
return content
return null
}
origin = {
@ -551,11 +559,12 @@ export function spreadNewStateFromOriginStateChange(
content = updateComponent(origin, content)
}
content = spreadNewStateFromOriginStateChange(
content =
spreadNewStateFromOriginStateChange(
actionTarget,
targetState,
content,
)
) ?? content
}
return content

@ -6,9 +6,10 @@ import {
} from "../model/tactic/Player"
import {
ComponentId,
StepContent,
TacticComponent,
TacticContent,
} from "../model/tactic/Tactic"
import { removeComponent, updateComponent } from "./TacticContentDomains"
import {
removeAllActionsTargeting,
@ -54,11 +55,22 @@ export function getPlayerNextTo(
: getComponent<PlayerLike>(pathItems[targetIdx - 1], components)
}
//FIXME this function can be a bottleneck if the phantom's position is
// following another phantom and / or the origin of the phantom is another
export function getPrecomputedPosition(
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(
phantom: PlayerPhantom,
content: TacticContent,
content: StepContent,
computedPositions: Map<string, Pos>,
area: DOMRect,
): Pos {
const positioning = phantom.pos
@ -66,6 +78,9 @@ export function computePhantomPositioning(
// If the position is already known and fixed, return the pos
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
// by calculating it from the referent position, and the action that targets the referent.
@ -76,7 +91,12 @@ export function computePhantomPositioning(
const referentPos =
referent.type === "player"
? referent.pos
: computePhantomPositioning(referent, content, area)
: computePhantomPositioning(
referent,
content,
computedPositions,
area,
)
// Get the origin
const origin = getOrigin(phantom, components)
@ -110,6 +130,7 @@ export function computePhantomPositioning(
? computePhantomPositioning(
playerBeforePhantom,
content,
computedPositions,
area,
)
: playerBeforePhantom.pos
@ -118,21 +139,29 @@ export function computePhantomPositioning(
const segment = posWithinBase(relativeTo(referentPos, pivotPoint), area)
const segmentLength = norm(segment)
const phantomDistanceFromReferent = PLAYER_RADIUS_PIXELS //TODO Place this in constants
const segmentProjection = minus(area, {
x: (segment.x / segmentLength) * phantomDistanceFromReferent,
y: (segment.y / segmentLength) * phantomDistanceFromReferent,
x: (segment.x / segmentLength) * PLAYER_RADIUS_PIXELS,
y: (segment.y / segmentLength) * PLAYER_RADIUS_PIXELS,
})
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>(
id: string,
components: TacticComponent[],
): 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) {
@ -171,8 +200,8 @@ export function isNextInPath(
export function clearPlayerPath(
player: Player,
content: TacticContent,
): TacticContent {
content: StepContent,
): StepContent {
if (player.path == null) {
return content
}
@ -192,8 +221,8 @@ export function clearPlayerPath(
function removeAllPhantomsAttached(
to: ComponentId,
content: TacticContent,
): TacticContent {
content: StepContent,
): StepContent {
let i = 0
while (i < content.components.length) {
const component = content.components[i]
@ -213,8 +242,8 @@ function removeAllPhantomsAttached(
export function removePlayer(
player: PlayerLike,
content: TacticContent,
): TacticContent {
content: StepContent,
): StepContent {
content = removeAllActionsTargeting(player.id, content)
content = removeAllPhantomsAttached(player.id, content)
@ -228,7 +257,7 @@ export function removePlayer(
content.components,
)!
const actions = playerBefore.actions.filter(
(a) => a.target === pos.attach,
(a) => a.target !== pos.attach,
)
content = updateComponent(
{
@ -253,10 +282,12 @@ export function removePlayer(
const actionTarget = content.components.find(
(c) => c.id === action.target,
)! as PlayerLike
return spreadNewStateFromOriginStateChange(
return (
spreadNewStateFromOriginStateChange(
actionTarget,
BallState.NONE,
content,
) ?? content
)
}
@ -266,8 +297,8 @@ export function removePlayer(
export function truncatePlayerPath(
player: Player,
phantom: PlayerPhantom,
content: TacticContent,
): TacticContent {
content: StepContent,
): StepContent {
if (player.path == null) return content
const path = player.path!
@ -296,11 +327,3 @@ export function truncatePlayerPath(
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 {
BallState,
Player,
PlayerInfo,
PlayerLike,
PlayerPhantom,
PlayerTeam,
} from "../model/tactic/Player"
import {
@ -14,13 +16,20 @@ import {
} from "../model/tactic/CourtObjects"
import {
ComponentId,
StepContent,
TacticComponent,
TacticContent,
} from "../model/tactic/Tactic"
import { overlaps } from "../geo/Box"
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 { spreadNewStateFromOriginStateChange } from "./ActionsDomains.ts"
export function placePlayerAt(
refBounds: DOMRect,
@ -38,6 +47,7 @@ export function placePlayerAt(
ballState: BallState.NONE,
path: null,
actions: [],
frozen: false,
}
}
@ -45,14 +55,14 @@ export function placeObjectAt(
refBounds: DOMRect,
courtBounds: DOMRect,
rackedObject: RackedCourtObject,
content: TacticContent,
): TacticContent {
content: StepContent,
): StepContent {
const pos = ratioWithinBase(refBounds, courtBounds)
let courtObject: CourtObject
switch (rackedObject.key) {
case BALL_TYPE:
case BALL_TYPE: {
const playerCollidedIdx = getComponentCollided(
refBounds,
content.components,
@ -67,9 +77,10 @@ export function placeObjectAt(
id: BALL_ID,
pos,
actions: [],
frozen: false,
}
break
}
default:
throw new Error("unknown court object " + rackedObject.key)
}
@ -82,9 +93,9 @@ export function placeObjectAt(
export function dropBallOnComponent(
targetedComponentIdx: number,
content: TacticContent,
content: StepContent,
setAsOrigin: boolean,
): TacticContent {
): StepContent {
const component = content.components[targetedComponentIdx]
if (component.type === "player" || component.type === "phantom") {
@ -95,13 +106,15 @@ export function dropBallOnComponent(
? BallState.HOLDS_ORIGIN
: BallState.HOLDS_BY_PASS
content = changePlayerBallState(component, newState, content)
content =
spreadNewStateFromOriginStateChange(component, newState, content) ??
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")
if (ballObjIdx == -1) {
@ -117,8 +130,8 @@ export function removeBall(content: TacticContent): TacticContent {
export function placeBallAt(
refBounds: DOMRect,
courtBounds: DOMRect,
content: TacticContent,
): TacticContent {
content: StepContent,
): StepContent {
if (!overlaps(courtBounds, refBounds)) {
return removeBall(content)
}
@ -141,6 +154,7 @@ export function placeBallAt(
id: BALL_ID,
pos,
actions: [],
frozen: false,
}
let components = content.components
@ -162,9 +176,9 @@ export function moveComponent(
component: TacticComponent,
info: PlayerInfo,
courtBounds: DOMRect,
content: TacticContent,
removed: (content: TacticContent) => TacticContent,
): TacticContent {
content: StepContent,
removed: (content: StepContent) => StepContent,
): StepContent {
const playerBounds = document
.getElementById(info.id)!
.getBoundingClientRect()
@ -232,8 +246,8 @@ export function moveComponent(
export function removeComponent(
componentId: ComponentId,
content: TacticContent,
): TacticContent {
content: StepContent,
): StepContent {
return {
...content,
components: content.components.filter((c) => c.id !== componentId),
@ -242,8 +256,8 @@ export function removeComponent(
export function updateComponent(
component: TacticComponent,
content: TacticContent,
): TacticContent {
content: StepContent,
): StepContent {
return {
...content,
components: content.components.map((c) =>
@ -287,3 +301,174 @@ export function getRackPlayers(
)
.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
}
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 }
/**

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

@ -1,10 +1,10 @@
import { Component } from "./Tactic"
import { Component, Frozable } from "./Tactic"
import { Pos } from "../../geo/Pos.ts"
export const BALL_ID = "ball"
export const BALL_TYPE = "ball"
export type Ball = Component<typeof BALL_TYPE, Pos> & Frozable
//place here all different kinds of objects
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"
export type PlayerId = string
@ -14,7 +14,7 @@ export enum PlayerTeam {
* All information about a player
*/
export interface PlayerInfo {
readonly id: string
readonly id: ComponentId
/**
* the player's team
* */
@ -25,28 +25,20 @@ export interface PlayerInfo {
* */
readonly role: string
/**
* True if the player has a basketball
*/
readonly ballState: BallState
readonly pos: Pos
}
export enum BallState {
NONE,
HOLDS_ORIGIN,
HOLDS_BY_PASS,
PASSED,
PASSED_ORIGIN,
NONE = "NONE",
HOLDS_ORIGIN = "HOLDS_ORIGIN",
HOLDS_BY_PASS = "HOLDS_BY_PASS",
PASSED = "PASSED",
PASSED_ORIGIN = "PASSED_ORIGIN",
}
export interface Player extends Component<"player", Pos>, PlayerInfo {
/**
* True if the player has a basketball
*/
readonly ballState: BallState
export interface Player extends Component<"player", Pos>, PlayerInfo, Frozable {
readonly path: MovementPath | null
}

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

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

@ -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%;
display: flex;
background-color: var(--main-color);
margin-bottom: 3px;
justify-content: space-between;
align-items: stretch;
}
#racks {
margin: 3px 6px 0 6px;
display: flex;
justify-content: space-between;
align-items: center;
@ -44,8 +44,29 @@
align-self: center;
}
#edit-div {
#editor-div {
display: flex;
flex-direction: row;
}
#content-div,
#editor-div,
#steps-div {
height: 100%;
width: 100%;
}
#content-div {
overflow: hidden;
}
.curtain {
width: 100%;
}
#steps-div {
background-color: var(--editor-tree-background);
overflow: scroll;
}
#allies-rack,
@ -112,7 +133,6 @@
height: 100%;
width: 100%;
user-select: none;
-webkit-user-drag: none;
}
#court-image * {
@ -130,6 +150,11 @@
font-family: monospace;
}
.save-state,
#show-steps-button {
user-select: none;
}
.save-state-error {
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;
--selection-color: #3f7fc4;
--selection-color-light: #acd8f8;
--border-color: #ffffff;
@ -29,4 +30,10 @@
--main-contrast-color: #e6edf3;
--font-title: 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: {
target: "es2021",
},
test: {
environment: "jsdom",
},
plugins: [
react(),
cssInjectedByJsPlugin({

Loading…
Cancel
Save