Add a visualizer #119

Merged
maxime.batista merged 4 commits from visualizer into master 1 year ago

@ -23,6 +23,7 @@ import {
import { BASE } from "./Constants.ts"
import { Authentication, Fetcher } from "./app/Fetcher.ts"
import { User } from "./model/User.ts"
import { VisualizerPage } from "./pages/VisualizerPage.tsx"
const HomePage = lazy(() => import("./pages/HomePage.tsx"))
const LoginPage = lazy(() => import("./pages/LoginPage.tsx"))
@ -110,7 +111,6 @@ export default function App() {
</LoggedInPage>,
)}
/>
<Route
path={"/settings"}
element={suspense(
@ -119,7 +119,6 @@ export default function App() {
</LoggedInPage>,
)}
/>
<Route
path={"/team/new"}
element={suspense(<CreateTeamPage />)}
@ -144,6 +143,21 @@ export default function App() {
</LoggedInPage>,
)}
/>
<Route
path={"/tactic/:tacticId/view"}
element={suspense(
<LoggedInPage>
<VisualizerPage guestMode={false} />
,
</LoggedInPage>,
)}
/>
<Route
path={"/tactic/view-guest"}
element={suspense(
<VisualizerPage guestMode={true} />,
)}
/>
<Route
path={"/tactic/edit-guest"}
element={suspense(

@ -68,9 +68,14 @@ export class Fetcher {
}
const nextToken = response.headers.get("Next-Authorization")!
const expirationDate = response.headers.get("Next-Authorization-Expiration-Date")!
const expirationDate = response.headers.get(
"Next-Authorization-Expiration-Date",
)!
if (nextToken && expirationDate) {
this.auth = { token: nextToken, expirationDate: new Date(expirationDate) }
this.auth = {
token: nextToken,
expirationDate: new Date(expirationDate),
}
}
return response

@ -0,0 +1,242 @@
import {
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import { APITacticService } from "../service/APITacticService.ts"
import {
CourtType,
StepContent,
StepInfoNode,
TacticComponent,
} from "../model/tactic/Tactic.ts"
import { getParent } from "../domains/StepsDomain.ts"
import {
computeRelativePositions,
getPhantomInfo,
} from "../domains/PlayerDomains.ts"
import { PlayerInfo, PlayerLike } from "../model/tactic/Player.ts"
import { CourtPlayer } from "./editor/CourtPlayer.tsx"
import { BALL_TYPE } from "../model/tactic/CourtObjects.ts"
import { CourtBallPiece } from "./editor/CourtBall.tsx"
import { CourtAction } from "./editor/CourtAction.tsx"
import { BasketCourt, Court } from "./editor/BasketCourt.tsx"
import { TacticService } from "../service/MutableTacticService.ts"
import { useAppFetcher } from "../App.tsx"
export interface VisualizerProps {
tacticId: number
stepId?: number
}
export function Visualizer({ tacticId, stepId }: VisualizerProps) {
const [panicMessage, setPanicMessage] = useState<string | null>(null)
const [courtType, setCourtType] = useState<CourtType | null>()
const [stepsTree, setStepsTree] = useState<StepInfoNode | null>()
const fetcher = useAppFetcher()
const service = useMemo(
() => new APITacticService(fetcher, tacticId),
[tacticId],
)
const isNotInit = !stepsTree || !courtType
useEffect(() => {
async function init() {
const contextResult = await service.getContext()
if (typeof contextResult === "string") {
setPanicMessage(contextResult)
return
}
const rootStep = contextResult.stepsTree
setStepsTree(rootStep)
setCourtType(contextResult.courtType)
}
if (isNotInit) init()
}, [isNotInit, service])
if (panicMessage) {
return <p>{panicMessage}</p>
}
if (isNotInit) {
return <p>Loading...</p>
}
return (
<StepVisualizer
courtType={courtType}
stepsTree={stepsTree}
stepId={stepId}
service={service}
/>
)
}
export interface StepVisualizerProps {
stepId?: number
stepsTree: StepInfoNode
courtType: CourtType
service: TacticService
}
export function StepVisualizer({
stepId,
stepsTree,
courtType,
service,
}: StepVisualizerProps) {
const [panicMessage, setPanicMessage] = useState<string | null>(null)
const [content, setContent] = useState<StepContent | null>(null)
const [parentContent, setParentContent] = useState<StepContent | null>()
const isNotInit = !content || !parentContent
useEffect(() => {
async function init() {
const contentStepId = stepId ?? stepsTree.id
const contentResult = await service.getContent(contentStepId)
if (typeof contentResult === "string") {
setPanicMessage(contentResult)
return
}
const stepParent = getParent(stepsTree, contentStepId)
let parentContent = null
if (stepParent) {
const parentResult = await service.getContent(contentStepId)
if (typeof parentResult === "string") {
setPanicMessage(parentResult)
return
}
parentContent = parentResult
}
setContent(contentResult)
setParentContent(parentContent)
}
if (isNotInit) init()
}, [isNotInit, service, stepId, stepsTree])
if (panicMessage) {
return <p>{panicMessage}</p>
}
if (isNotInit) {
return <p>Loading Content...</p>
}
return (
<VisualizerFrame
content={content}
parentContent={parentContent}
courtType={courtType}
/>
)
}
export interface VisualizerFrameProps {
content: StepContent
parentContent: StepContent | null
courtType: CourtType
}
export function VisualizerFrame({
content,
parentContent,
courtType,
}: VisualizerFrameProps) {
const courtRef = useRef<HTMLDivElement>(null)
const courtBounds = useCallback(
() => courtRef.current!.getBoundingClientRect(),
[courtRef],
)
const relativePositions = useMemo(() => {
const courtBounds = courtRef.current?.getBoundingClientRect()
return courtBounds
? computeRelativePositions(courtBounds, content)
: new Map()
}, [content, courtRef])
const renderPlayer = useCallback(
(component: PlayerLike, isFromParent: boolean) => {
let info: PlayerInfo
const isPhantom = component.type == "phantom"
const usedContent = isFromParent ? parentContent! : content
if (isPhantom) {
info = getPhantomInfo(
component,
usedContent,
relativePositions,
courtBounds(),
)
} else {
info = component
}
const className =
(isPhantom ? "phantom" : "player") +
" " +
(isFromParent ? "from-parent" : "")
return (
<CourtPlayer
key={component.id}
playerInfo={info}
className={className}
availableActions={() => []}
/>
)
},
[content, courtBounds, parentContent, relativePositions],
)
const renderComponent = useCallback(
(component: TacticComponent, isFromParent: boolean): ReactNode => {
if (component.type === "player" || component.type === "phantom") {
return renderPlayer(component, isFromParent)
}
if (component.type === BALL_TYPE) {
return <CourtBallPiece key="ball" pos={component.pos} />
}
return <></>
},
[renderPlayer],
)
const renderActions = useCallback(
(component: TacticComponent, isFromParent: boolean) =>
component.actions.map((action, i) => {
return (
<CourtAction
key={"action-" + component.id + "-" + i}
action={action}
origin={component.id}
courtRef={courtRef}
color={isFromParent ? "gray" : "black"}
isEditable={false}
/>
)
}),
[courtRef],
)
return (
<BasketCourt
parentComponents={parentContent?.components ?? null}
components={content.components}
courtImage={<Court courtType={courtType} />}
courtRef={courtRef}
renderComponent={renderComponent}
renderActions={renderActions}
/>
)
}

@ -1,13 +1,19 @@
import { ReactElement, ReactNode, RefObject } from "react"
import { ReactElement, ReactNode, RefObject, useEffect, useState } from "react"
import { Action } from "../../model/tactic/Action"
import { CourtAction } from "./CourtAction"
import { ComponentId, TacticComponent } from "../../model/tactic/Tactic"
import {
ComponentId,
CourtType,
TacticComponent,
} from "../../model/tactic/Tactic"
import PlainCourt from "../../assets/court/full_court.svg?react"
import HalfCourt from "../../assets/court/half_court.svg?react"
export interface BasketCourtProps {
components: TacticComponent[]
parentComponents: TacticComponent[] | null
previewAction: ActionPreview | null
previewAction?: ActionPreview | null
renderComponent: (comp: TacticComponent, isFromParent: boolean) => ReactNode
renderActions: (comp: TacticComponent, isFromParent: boolean) => ReactNode[]
@ -32,6 +38,13 @@ export function BasketCourt({
courtImage,
courtRef,
}: BasketCourtProps) {
const [court, setCourt] = useState(courtRef.current)
//force update once the court reference is set
useEffect(() => {
setCourt(courtRef.current)
}, [courtRef])
return (
<div
className="court-container"
@ -39,15 +52,11 @@ export function BasketCourt({
style={{ position: "relative" }}>
{courtImage}
{courtRef.current &&
parentComponents?.map((i) => renderComponent(i, true))}
{courtRef.current &&
parentComponents?.flatMap((i) => renderActions(i, true))}
{court && parentComponents?.map((i) => renderComponent(i, true))}
{court && parentComponents?.flatMap((i) => renderActions(i, true))}
{courtRef.current &&
components.map((i) => renderComponent(i, false))}
{courtRef.current &&
components.flatMap((i) => renderActions(i, false))}
{court && components.map((i) => renderComponent(i, false))}
{court && components.flatMap((i) => renderActions(i, false))}
{previewAction && (
<CourtAction
@ -64,3 +73,12 @@ export function BasketCourt({
</div>
)
}
export function Court({ courtType }: { courtType: CourtType }) {
const CourtSvg = courtType === "PLAIN" ? PlainCourt : HalfCourt
return (
<div className="court-image-div">
<CourtSvg className="court-image" />
</div>
)
}

@ -20,11 +20,37 @@ export function CourtBall({
}: EditableCourtBallProps) {
const pieceRef = useRef<HTMLDivElement>(null)
function courtBallPiece(
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>
)
}
interface CourtBallPieceProps {
pos: Pos
}
export function CourtBallPiece({ pos }: CourtBallPieceProps) {
return courtBallPiece(pos)
}
function courtBallPiece(
{ x, y }: Pos,
pieceRef?: RefObject<HTMLDivElement>,
onKeyUp?: KeyboardEventHandler,
) {
) {
return (
<div
className={"ball-div"}
@ -39,22 +65,4 @@ export function CourtBall({
<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>
)
}

@ -4,14 +4,14 @@ 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"
import { getStepName } from "../../domains/StepsDomain.ts"
export interface StepsTreeProps {
root: StepInfoNode
selectedStepId: number
onAddChildren: (parent: StepInfoNode) => void
onRemoveNode: (node: StepInfoNode) => void
onStepSelected: (node: StepInfoNode) => void
onAddChildren?: (parent: StepInfoNode) => void
onRemoveNode?: (node: StepInfoNode) => void
onStepSelected?: (node: StepInfoNode) => void
}
export default function StepsTree({
@ -40,9 +40,9 @@ interface StepsTreeContentProps {
rootNode: StepInfoNode
selectedStepId: number
onAddChildren: (parent: StepInfoNode) => void
onRemoveNode: (node: StepInfoNode) => void
onStepSelected: (node: StepInfoNode) => void
onAddChildren?: (parent: StepInfoNode) => void
onRemoveNode?: (node: StepInfoNode) => void
onStepSelected?: (node: StepInfoNode) => void
}
function StepsTreeNode({
@ -79,13 +79,17 @@ function StepsTreeNode({
<StepPiece
id={node.id}
isSelected={selectedStepId === node.id}
onAddButtonClicked={() => onAddChildren(node)}
onAddButtonClicked={
onAddChildren ? () => onAddChildren(node) : undefined
}
onRemoveButtonClicked={
rootNode.id === node.id
rootNode.id === node.id || !onRemoveNode
? undefined
: () => onRemoveNode(node)
}
onSelected={() => onStepSelected(node)}>
onSelected={() => {
if (onStepSelected) onStepSelected(node)
}}>
<p>
{useMemo(
() => getStepName(rootNode, node.id),

@ -3,16 +3,16 @@ import {
Player,
PlayerLike,
PlayerPhantom,
} from "../model/tactic/Player"
import { ratioWithinBase } from "../geo/Pos"
} from "../model/tactic/Player.ts"
import { ratioWithinBase } from "../geo/Pos.ts"
import {
ComponentId,
StepContent,
TacticComponent,
} from "../model/tactic/Tactic"
import { overlaps } from "../geo/Box"
import { Action, ActionKind, moves } from "../model/tactic/Action"
import { removeBall, updateComponent } from "./TacticContentDomains"
} from "../model/tactic/Tactic.ts"
import { overlaps } from "../geo/Box.ts"
import { Action, ActionKind, moves } from "../model/tactic/Action.ts"
import { removeBall, updateComponent } from "./TacticContentDomains.ts"
import {
areInSamePath,
getComponent,
@ -20,8 +20,8 @@ import {
getPlayerNextTo,
isNextInPath,
removePlayer,
} from "./PlayerDomains"
import { BALL_TYPE } from "../model/tactic/CourtObjects"
} from "./PlayerDomains.ts"
import { BALL_TYPE } from "../model/tactic/CourtObjects.ts"
export function getActionKind(
target: TacticComponent | null,

@ -1,21 +1,22 @@
import {
BallState,
Player,
PlayerInfo,
PlayerLike,
PlayerPhantom,
} from "../model/tactic/Player"
} from "../model/tactic/Player.ts"
import {
ComponentId,
StepContent,
TacticComponent,
} from "../model/tactic/Tactic"
} from "../model/tactic/Tactic.ts"
import { removeComponent, updateComponent } from "./TacticContentDomains"
import { removeComponent, updateComponent } from "./TacticContentDomains.ts"
import {
removeAllActionsTargeting,
spreadNewStateFromOriginStateChange,
} from "./ActionsDomains"
import { ActionKind } from "../model/tactic/Action"
} from "./ActionsDomains.ts"
import { ActionKind } from "../model/tactic/Action.ts"
import {
add,
minus,
@ -311,7 +312,7 @@ export function truncatePlayerPath(
for (let i = truncateStartIdx; i < path.items.length; i++) {
const pathPhantomId = path.items[i]
//remove the phantom from the tactic
//remove the phantom from the domains
content = removeComponent(pathPhantomId, content)
content = removeAllActionsTargeting(pathPhantomId, content)
}
@ -330,3 +331,46 @@ export function truncatePlayerPath(
content,
)
}
export function getPhantomInfo(
phantom: PlayerPhantom,
content: StepContent,
relativePositions: ComputedRelativePositions,
courtBounds: DOMRect,
): PlayerInfo {
const origin = getOrigin(phantom, content.components)
return {
id: phantom.id,
team: origin.team,
role: origin.role,
pos: computePhantomPositioning(
phantom,
content,
relativePositions,
courtBounds,
),
ballState: phantom.ballState,
}
}
export type ComputedRelativePositions = Map<ComponentId, Pos>
export 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
}

@ -1,4 +1,4 @@
import { StepInfoNode } from "../model/tactic/Tactic"
import { StepInfoNode } from "../model/tactic/Tactic.ts"
export function addStepNode(
root: StepInfoNode,

@ -1,4 +1,4 @@
import { equals, Pos, ratioWithinBase } from "../geo/Pos"
import { equals, Pos, ratioWithinBase } from "../geo/Pos.ts"
import {
BallState,
@ -7,28 +7,28 @@ import {
PlayerLike,
PlayerPhantom,
PlayerTeam,
} from "../model/tactic/Player"
} from "../model/tactic/Player.ts"
import {
Ball,
BALL_ID,
BALL_TYPE,
CourtObject,
} from "../model/tactic/CourtObjects"
} from "../model/tactic/CourtObjects.ts"
import {
ComponentId,
StepContent,
TacticComponent,
} from "../model/tactic/Tactic"
} from "../model/tactic/Tactic.ts"
import { overlaps } from "../geo/Box"
import { RackedCourtObject, RackedPlayer } from "./RackedItems"
import { overlaps } from "../geo/Box.ts"
import { RackedCourtObject, RackedPlayer } from "../editor/RackedItems.ts"
import {
getComponent,
getOrigin,
getPrecomputedPosition,
removePlayer,
tryGetComponent,
} from "./PlayerDomains"
} from "./PlayerDomains.ts"
import { Action, ActionKind } from "../model/tactic/Action.ts"
import { spreadNewStateFromOriginStateChange } from "./ActionsDomains.ts"
@ -418,6 +418,21 @@ export function drainTerminalStateOnChildContent(
continue
}
if (childComponent.type !== parentComponent.type)
throw Error("child and parent components are not of the same type.")
if (childComponent.type === "ball" && parentComponent.type === "ball") {
gotUpdated = true
childContent = updateComponent(
{
...childComponent,
frozen: true,
pos: parentComponent.pos,
},
childContent,
)
}
// ensure that the component is a player
if (
parentComponent.type !== "player" ||
@ -439,7 +454,7 @@ export function drainTerminalStateOnChildContent(
newContentResult?.components,
)
}
// update the position of the player if it has been moved
// update the position of the component 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 ||
@ -459,6 +474,7 @@ export function drainTerminalStateOnChildContent(
const initialChildCompsCount = childContent.components.length
//remove players if they are not present on the parent's anymore
for (const component of childContent.components) {
if (
component.type !== "phantom" &&

@ -1,5 +1,6 @@
import {
CSSProperties,
ReactNode,
RefObject,
SetStateAction,
useCallback,
@ -10,8 +11,6 @@ import {
} from "react"
import "../style/editor.css"
import TitleInput from "../components/TitleInput"
import PlainCourt from "../assets/court/full_court.svg?react"
import HalfCourt from "../assets/court/half_court.svg?react"
import { BallPiece } from "../components/editor/BallPiece"
@ -19,7 +18,6 @@ import { Rack } from "../components/Rack"
import { PlayerPiece } from "../components/editor/PlayerPiece"
import {
ComponentId,
CourtType,
StepContent,
StepInfoNode,
@ -33,7 +31,11 @@ import SavingState, {
import { BALL_TYPE } from "../model/tactic/CourtObjects"
import { CourtAction } from "../components/editor/CourtAction"
import { ActionPreview, BasketCourt } from "../components/editor/BasketCourt"
import {
ActionPreview,
BasketCourt,
Court,
} from "../components/editor/BasketCourt"
import { overlaps } from "../geo/Box"
import {
@ -50,7 +52,7 @@ import {
removeBall,
selectContent,
updateComponent,
} from "../editor/TacticContentDomains"
} from "../domains/TacticContentDomains.ts"
import {
BallState,
@ -71,16 +73,18 @@ import {
isActionValid,
removeAction,
spreadNewStateFromOriginStateChange,
} from "../editor/ActionsDomains"
} from "../domains/ActionsDomains.ts"
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 {
computePhantomPositioning,
ComputedRelativePositions,
computeRelativePositions,
getOrigin,
getPhantomInfo,
removePlayer,
} from "../editor/PlayerDomains"
} from "../domains/PlayerDomains.ts"
import { CourtBall } from "../components/editor/CourtBall"
import StepsTree from "../components/editor/StepsTree"
import {
@ -88,12 +92,15 @@ import {
getParent,
getStepNode,
removeStepNode,
} from "../editor/StepsDomain"
} from "../domains/StepsDomain.ts"
import SplitLayout from "../components/SplitLayout.tsx"
import { ServiceError, TacticService } from "../service/TacticService.ts"
import {
MutableTacticService,
ServiceError,
} from "../service/MutableTacticService.ts"
import { LocalStorageTacticService } from "../service/LocalStorageTacticService.ts"
import { APITacticService } from "../service/APITacticService.ts"
import { useParams } from "react-router-dom"
import { useNavigate, useParams } from "react-router-dom"
import { ContentVersions } from "../editor/ContentVersions.ts"
import { useAppFetcher } from "../App.tsx"
@ -101,21 +108,15 @@ const ERROR_STYLE: CSSProperties = {
borderColor: "red",
}
type ComputedRelativePositions = Map<ComponentId, Pos>
type ComputedStepContent = {
content: StepContent
relativePositions: ComputedRelativePositions
}
export interface EditorPageProps {
export interface EditorProps {
guestMode: boolean
}
export default function Editor({ guestMode }: EditorPageProps) {
return <EditorPortal guestMode={guestMode} />
}
interface EditorService {
addStep(
parent: StepInfoNode,
@ -129,28 +130,47 @@ interface EditorService {
setContent(content: SetStateAction<StepContent>): void
setName(name: string): Promise<SaveState>
openVisualizer(): Promise<void>
}
function EditorPortal({ guestMode }: EditorPageProps) {
export default function Editor({ guestMode }: EditorProps) {
const { tacticId: idStr } = useParams()
const fetcher = useAppFetcher()
const navigate = useNavigate()
if (guestMode || !idStr) {
return <EditorPageWrapper service={LocalStorageTacticService.init()} />
return (
<EditorPageWrapper
service={LocalStorageTacticService.init()}
openVisualizer={() => navigate("/tactic/view-guest")}
/>
)
}
return (
<EditorPageWrapper
service={new APITacticService(fetcher, parseInt(idStr))}
openVisualizer={() => navigate(`/tactic/${idStr}/view`)}
/>
)
}
function EditorPageWrapper({ service }: { service: TacticService }) {
interface EditorPageWrapperProps {
service: MutableTacticService
openVisualizer(): void
}
function EditorPageWrapper({
service,
openVisualizer,
}: EditorPageWrapperProps) {
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>()
const [parentContent, setParentContent] = useState<StepContent | null>(null)
const courtRef = useRef<HTMLDivElement>(null)
@ -209,8 +229,6 @@ function EditorPageWrapper({ service }: { service: TacticService }) {
[stepsVersions, service, stepId, stepsTree],
)
const [parentContent, setParentContent] = useState<StepContent | null>(null)
const [stepContent, setStepContent, saveState] =
useContentState<StepContent>(
{ components: [] },
@ -274,7 +292,6 @@ function EditorPageWrapper({ service }: { service: TacticService }) {
stepsVersions.set(stepId, versions)
versions.insertAndCut(contentResult)
console.log(contentResult)
setStepContent(contentResult, false)
}
@ -334,8 +351,12 @@ function EditorPageWrapper({ service }: { service: TacticService }) {
setStepId(step)
setStepContent(result, false)
},
async openVisualizer(): Promise<void> {
openVisualizer()
},
}
}, [stepsVersions, service, setStepContent, stepsTree])
}, [stepsTree, service, stepsVersions, setStepContent, openVisualizer])
if (panicMessage) {
return <p>{panicMessage}</p>
@ -399,7 +420,7 @@ function EditorPage({
null,
)
const [isStepsTreeVisible, setStepsTreeVisible] = useState(false)
const [isStepsTreeVisible, setStepsTreeVisible] = useState(true)
const courtBounds = useCallback(
() => courtRef.current!.getBoundingClientRect(),
@ -543,6 +564,7 @@ function EditorPage({
content,
courtRef,
doMoveBall,
parentContent,
previewAction?.isInvalid,
service.setContent,
],
@ -558,22 +580,14 @@ function EditorPage({
const usedContent = isFromParent ? parentContent! : content
if (isPhantom) {
const origin = getOrigin(component, usedContent.components)
info = {
id: component.id,
team: origin.team,
role: origin.role,
pos: computePhantomPositioning(
info = getPhantomInfo(
component,
usedContent,
relativePositions,
courtBounds(),
),
ballState: component.ballState,
}
)
} else {
info = component
forceFreeze ||= component.frozen
}
@ -612,8 +626,9 @@ function EditorPage({
)
},
[
courtRef,
parentContent,
content,
courtRef,
relativePositions,
courtBounds,
renderAvailablePlayerActions,
@ -649,7 +664,7 @@ function EditorPage({
)
const renderComponent = useCallback(
(component: TacticComponent, isFromParent: boolean) => {
(component: TacticComponent, isFromParent: boolean): ReactNode => {
if (component.type === "player" || component.type === "phantom") {
return renderPlayer(component, isFromParent)
}
@ -669,7 +684,7 @@ function EditorPage({
/>
)
}
throw new Error("unknown tactic component " + component)
return <></>
},
[service, renderPlayer, doMoveBall],
)
@ -799,7 +814,6 @@ function EditorPage({
<div id="topbar-left">
<SavingState state={contentSaveState} />
</div>
<div id="title-input-div">
<TitleInput
style={titleStyle}
default_value={name}
@ -807,17 +821,19 @@ function EditorPage({
(new_name) => {
service.setName(new_name).then((state) => {
setTitleStyle(
state == SaveStates.Ok
? {}
: ERROR_STYLE,
state == SaveStates.Ok ? {} : ERROR_STYLE,
)
})
},
[service],
)}
/>
</div>
<div id="topbar-right">
<button
id="toggle-visualisation"
onClick={service.openVisualizer}>
VISUALISER
</button>
<button
id={"show-steps-button"}
onClick={() => setStepsTreeVisible((b) => !b)}>
@ -1060,18 +1076,6 @@ function renderCourtObject(courtObject: RackedCourtObject) {
throw new Error("unknown racked court object " + courtObject.key)
}
function Court({ courtType }: { courtType: string }) {
return (
<div id="court-image-div">
{courtType == "PLAIN" ? (
<PlainCourt id="court-image" />
) : (
<HalfCourt id="court-image" />
)}
</div>
)
}
function debounceAsync<A, B>(
f: (args: A) => Promise<B>,
delay = 1000,
@ -1119,22 +1123,6 @@ function useContentState<S>(
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,

@ -52,7 +52,9 @@ export default function ProfileSettings() {
useEffect(() => {
passwordConfirmRef.current!.setCustomValidity(
password === confirmPassword ? "" : "Les mots de passe ne correspondent pas !"
password === confirmPassword
? ""
: "Les mots de passe ne correspondent pas !",
)
}, [confirmPassword, password])
@ -110,9 +112,8 @@ export default function ProfileSettings() {
autoComplete="username"
required
placeholder="Nom d'utilisateur"
value={name}
onChange={e => setName(e.target.value)}
onChange={(e) => setName(e.target.value)}
/>
<label htmlFor="email">Adresse email</label>
@ -124,9 +125,8 @@ export default function ProfileSettings() {
placeholder={"Adresse email"}
autoComplete="email"
required
value={email}
onChange={e => setEmail(e.target.value)}
onChange={(e) => setEmail(e.target.value)}
/>
<label htmlFor="password">Mot de passe</label>
@ -137,12 +137,13 @@ export default function ProfileSettings() {
type="password"
placeholder={"Mot de passe"}
autoComplete="new-password"
value={password}
onChange={e => setPassword(e.target.value)}
onChange={(e) => setPassword(e.target.value)}
/>
<label htmlFor="confirmPassword">Confirmez le mot de passe</label>
<label htmlFor="confirmPassword">
Confirmez le mot de passe
</label>
<input
ref={passwordConfirmRef}
className="settings-input"
@ -151,14 +152,13 @@ export default function ProfileSettings() {
type="password"
autoComplete="new-password"
placeholder={"Confirmation du mot de passe"}
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
onChange={(e) =>
setConfirmPassword(e.target.value)
}
/>
<button
className="settings-button"
type="submit">
<button className="settings-button" type="submit">
Mettre à jour
</button>
</form>
@ -207,11 +207,12 @@ function ProfileImageInputPopup({ show, onHide }: ProfileImageInputPopupProps) {
if (e.key === "Escape") onHide()
}
window.addEventListener('keyup', onKeyUp)
return () => window.removeEventListener('keyup', onKeyUp)
window.addEventListener("keyup", onKeyUp)
return () => window.removeEventListener("keyup", onKeyUp)
}, [onHide])
const handleForm = useCallback(async (e: FormEvent) => {
const handleForm = useCallback(
async (e: FormEvent) => {
e.preventDefault()
const url = urlRef.current!.value
@ -225,7 +226,9 @@ function ProfileImageInputPopup({ show, onHide }: ProfileImageInputPopupProps) {
setUser({ ...user!, profilePicture: url })
setErrorMessages([])
onHide()
}, [fetcher, onHide, setUser, user])
},
[fetcher, onHide, setUser, user],
)
if (!show) return <></>
@ -243,7 +246,9 @@ function ProfileImageInputPopup({ show, onHide }: ProfileImageInputPopupProps) {
{msg}
</div>
))}
<label id="profile-picture-popup-subtitle" htmlFor="profile-picture">
<label
id="profile-picture-popup-subtitle"
htmlFor="profile-picture">
Saisissez le lien vers votre nouvelle photo de profil
</label>
<input
@ -260,7 +265,7 @@ function ProfileImageInputPopup({ show, onHide }: ProfileImageInputPopupProps) {
required
placeholder={"lien vers une image"}
value={link}
onChange={e => setLink(e.target.value)}
onChange={(e) => setLink(e.target.value)}
/>
<div id="profile-picture-popup-footer">
<button className={"settings-button"} onClick={onHide}>

@ -1,23 +0,0 @@
// import React, { CSSProperties, useState } from "react"
// import "../style/visualizer.css"
// import Court from "../assets/court/full_court.svg"
//
// export default function Visualizer({ id, name }: { id: number; name: string }) {
// const [style, setStyle] = useState<CSSProperties>({})
//
// return (
// <div id="main">
// <div id="topbar">
// <h1>{name}</h1>
// </div>
// <div id="court-container">
// <img
// id="court"
// src={Court}
// style={style}
// alt="Basketball Court"
// />
// </div>
// </div>
// )
// }

@ -0,0 +1,220 @@
import { ServiceError, TacticService } from "../service/MutableTacticService.ts"
import { useNavigate, useParams } from "react-router-dom"
import { useCallback, useEffect, useMemo, useState } from "react"
import {
useVisualizer,
VisualizerState,
VisualizerStateActionKind,
} from "../visualizer/VisualizerState.ts"
import { getParent } from "../domains/StepsDomain.ts"
import { mapToParentContent } from "../domains/TacticContentDomains.ts"
import StepsTree from "../components/editor/StepsTree.tsx"
import { StepInfoNode } from "../model/tactic/Tactic.ts"
import SplitLayout from "../components/SplitLayout.tsx"
import { LocalStorageTacticService } from "../service/LocalStorageTacticService.ts"
import { APITacticService } from "../service/APITacticService.ts"
import "../style/visualizer.css"
import { VisualizerFrame } from "../components/Visualizer.tsx"
import { useAppFetcher } from "../App.tsx"
export interface VisualizerPageProps {
guestMode: boolean
}
export function VisualizerPage({ guestMode }: VisualizerPageProps) {
const { tacticId: idStr } = useParams()
const navigate = useNavigate()
const fetcher = useAppFetcher()
if (guestMode || !idStr) {
return (
<ServedVisualizerPage
service={LocalStorageTacticService.init()}
openEditor={() => navigate("/tactic/edit-guest")}
/>
)
}
return (
<ServedVisualizerPage
service={new APITacticService(fetcher, parseInt(idStr))}
openEditor={() => navigate(`/tactic/${idStr}/edit`)}
/>
)
}
interface VisualizerService {
selectStep(step: number): Promise<void | ServiceError>
openEditor(): Promise<void>
}
interface ServedVisualizerPageProps {
service: TacticService
openEditor: () => void
}
function ServedVisualizerPage({
service,
openEditor,
}: ServedVisualizerPageProps) {
const [panicMessage, setPanicMessage] = useState<string>()
const [state, dispatch] = useVisualizer(null)
const [canEdit, setCanEdit] = useState(false)
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
}
const stepId = contextResult.stepsTree.id
const contentResult = await service.getContent(stepId)
if (typeof contentResult === "string") {
setPanicMessage(
"There has been an error retrieving the tactic's root step content : " +
contentResult,
)
return
}
setCanEdit(await service.canBeEdited())
dispatch({
type: VisualizerStateActionKind.INIT,
state: {
stepId,
stepsTree: contextResult.stepsTree,
courtType: contextResult.courtType,
tacticName: contextResult.name,
content: contentResult,
parentContent: null,
},
})
}
if (state === null) init()
}, [service, state])
const visualizerService: VisualizerService = useMemo(
() => ({
async selectStep(step: number): Promise<void | ServiceError> {
const result = await service.getContent(step)
if (typeof result === "string") return result
const stepParent = getParent(state!.stepsTree!, step)?.id
let parentContent = null
if (stepParent) {
const parentResult = await service.getContent(stepParent)
if (typeof parentResult === "string") return parentResult
parentContent = mapToParentContent(parentResult)
}
dispatch({
type: VisualizerStateActionKind.SET_CONTENTS,
content: result,
parentContent,
stepId: step,
})
},
async openEditor() {
openEditor()
},
}),
[openEditor, service, state],
)
if (panicMessage) {
return <p>{panicMessage}</p>
}
if (state === null) {
return <p>Retrieving tactic context. Please wait...</p>
}
return (
<VisualizerPageContent
state={state}
service={visualizerService}
showEditButton={canEdit}
/>
)
}
interface VisualizerPageContentProps {
state: VisualizerState
service: VisualizerService
showEditButton: boolean
}
function VisualizerPageContent({
state: { content, parentContent, stepId, stepsTree, courtType, tacticName },
service,
showEditButton,
}: VisualizerPageContentProps) {
const [isStepsTreeVisible, setStepsTreeVisible] = useState(true)
const [editorContentCurtainWidth, setEditorContentCurtainWidth] =
useState(80)
const stepsTreeNode = (
<div id={"steps-div"}>
<StepsTree
root={stepsTree}
selectedStepId={stepId}
onStepSelected={useCallback(
(node: StepInfoNode) => service.selectStep(node.id),
[service],
)}
/>
</div>
)
const contentNode = (
<div id="content-div">
<VisualizerFrame
content={content}
parentContent={parentContent}
courtType={courtType}
/>
</div>
)
return (
<div id="visualizer">
<div id="header-page">
<p id="title">{tacticName}</p>
<div id="header-page-right">
<button
id={"show-steps-button"}
onClick={() => setStepsTreeVisible((b) => !b)}>
ETAPES
</button>
{showEditButton && (
<button onClick={() => service.openEditor()}>
EDITER
</button>
)}
</div>
</div>
<div id="editor-div">
{isStepsTreeVisible ? (
<SplitLayout
rightWidth={editorContentCurtainWidth}
onRightWidthChange={setEditorContentCurtainWidth}>
{contentNode}
{stepsTreeNode}
</SplitLayout>
) : (
contentNode
)}
</div>
</div>
)
}

@ -1,8 +1,12 @@
import { TacticService, ServiceError, TacticContext } from "./TacticService.ts"
import {
MutableTacticService,
ServiceError,
TacticContext,
} from "./MutableTacticService.ts"
import { StepContent, StepInfoNode } from "../model/tactic/Tactic.ts"
import { Fetcher } from "../app/Fetcher.ts"
export class APITacticService implements TacticService {
export class APITacticService implements MutableTacticService {
private readonly tacticId: number
private readonly fetcher: Fetcher
@ -11,6 +15,14 @@ export class APITacticService implements TacticService {
this.fetcher = fetcher
}
async canBeEdited(): Promise<boolean> {
const response = await this.fetcher.fetchAPIGet(
`tactics/${this.tacticId}/can-edit`,
)
const { canEdit } = await response.json()
return canEdit
}
async getContext(): Promise<TacticContext | ServiceError> {
const infoResponsePromise = this.fetcher.fetchAPIGet(
`tactics/${this.tacticId}`,

@ -1,18 +1,26 @@
import { TacticService, ServiceError, TacticContext } from "./TacticService.ts"
import {
MutableTacticService,
ServiceError,
TacticContext,
} from "./MutableTacticService.ts"
import { StepContent, StepInfoNode } from "../model/tactic/Tactic.ts"
import {
addStepNode,
getAvailableId,
removeStepNode,
} from "../editor/StepsDomain.ts"
} from "../domains/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 {
export class LocalStorageTacticService implements MutableTacticService {
private constructor() {}
canBeEdited(): Promise<boolean> {
return Promise.resolve(true)
}
static init(): LocalStorageTacticService {
const root = localStorage.getItem(
GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY,
@ -25,7 +33,7 @@ export class LocalStorageTacticService implements TacticService {
)
localStorage.setItem(
GUEST_MODE_STEP_CONTENT_STORAGE_KEY + 1,
JSON.stringify(<StepContent>{components: []})
JSON.stringify(<StepContent>{ components: [] }),
)
}

@ -13,7 +13,11 @@ export enum ServiceError {
export interface TacticService {
getContext(): Promise<TacticContext | ServiceError>
getContent(step: number): Promise<StepContent | ServiceError>
canBeEdited(): Promise<boolean>
}
export interface MutableTacticService extends TacticService {
addStep(
parent: StepInfoNode,
content: StepContent,
@ -27,6 +31,4 @@ export interface TacticService {
step: number,
content: StepContent,
): Promise<void | ServiceError>
getContent(step: number): Promise<StepContent | ServiceError>
}

@ -0,0 +1,24 @@
.court-image-div {
position: relative;
background-color: white;
height: 80vh;
}
.court-container {
display: flex;
align-content: center;
align-items: center;
justify-content: center;
background-color: black;
}
.court-image {
height: 100%;
width: 100%;
user-select: none;
}
.court-image * {
stroke: var(--selected-team-secondarycolor);
}

@ -1,4 +1,6 @@
@import "theme/default.css";
@import "court.css";
@import "tactic.css";
#main-div {
display: flex;
@ -37,11 +39,14 @@
justify-content: space-between;
align-items: center;
height: 25px;
z-index: 1;
align-content: space-between;
width: 100%;
}
.title-input {
width: 25ch;
align-self: center;
}
#editor-div {
@ -58,6 +63,8 @@
#content-div {
overflow: hidden;
display: flex;
flex-direction: column;
}
.curtain {
@ -94,14 +101,6 @@
margin-left: 5px;
}
.player-piece.opponents {
background-color: var(--player-opponents-color);
}
.player-piece.allies {
background-color: var(--player-allies-color);
}
#court-div {
background-color: var(--background-color);
@ -114,35 +113,6 @@
align-content: center;
}
#court-image-div {
position: relative;
background-color: white;
height: 80vh;
}
.court-container {
display: flex;
align-content: center;
align-items: center;
justify-content: center;
height: 75%;
}
#court-image {
height: 100%;
width: 100%;
user-select: none;
}
#court-image * {
stroke: var(--selected-team-secondarycolor);
}
.react-draggable {
z-index: 2;
}
.save-state {
display: flex;
align-items: center;

@ -8,7 +8,7 @@
.from-parent .player-piece {
color: white;
background-color: var(--player-from-parent-color);
background-color: var(--player-from-parent-color) !important;
}
.player-content {

@ -0,0 +1,7 @@
.player-piece.opponents {
background-color: var(--player-opponents-color);
}
.player-piece.allies {
background-color: var(--player-allies-color);
}

@ -1,30 +1,72 @@
#main {
height: 100vh;
width: 100%;
@import "court.css";
@import "theme/default.css";
@import "player.css";
@import "tactic.css";
#visualizer {
display: flex;
height: 100%;
width: 100%;
background-color: var(--background-color);
flex-direction: column;
overflow: hidden;
}
#topbar {
#editor-div {
height: 100%;
}
.curtain {
width: 100%;
height: 100%;
}
#content-div {
display: flex;
background-color: var(--main-color);
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
#header-page {
width: 100%;
display: flex;
background-color: var(--main-color);
justify-content: flex-end;
align-content: flex-end;
}
#header-page-right {
display: flex;
user-select: none;
align-self: flex-end;
height: 100%;
}
h1 {
#title {
align-self: center;
margin: 0;
user-select: none;
position: absolute;
width: 100%;
text-align: center;
margin-top: 0;
pointer-events: none;
}
#court-container {
flex: 1;
#topbar {
display: flex;
justify-content: center;
background-color: var(--main-color);
justify-content: center;
align-items: center;
}
#court {
max-width: 80%;
max-height: 80%;
#steps-div {
background-color: var(--editor-tree-background);
overflow: scroll;
height: 100%;
}

@ -0,0 +1,50 @@
import { CourtType, StepContent, StepInfoNode } from "../model/tactic/Tactic.ts"
import { useReducer } from "react"
export interface VisualizerState {
stepId: number
tacticName: string
courtType: CourtType
stepsTree: StepInfoNode
content: StepContent
parentContent: StepContent | null
}
export const enum VisualizerStateActionKind {
INIT,
SET_CONTENTS,
}
export type VisualizerStateAction =
| {
type: VisualizerStateActionKind.INIT
state: VisualizerState
}
| {
type: VisualizerStateActionKind.SET_CONTENTS
content: StepContent
parentContent: StepContent | null
stepId: number
}
export function useVisualizer(initialState: VisualizerState | null) {
return useReducer(visualizerStateReducer, initialState)
}
function visualizerStateReducer(
state: VisualizerState | null,
action: VisualizerStateAction,
): VisualizerState | null {
switch (action.type) {
case VisualizerStateActionKind.INIT:
return action.state
case VisualizerStateActionKind.SET_CONTENTS:
if (state === null) throw Error("State is uninitialized !")
return {
...state,
stepId: action.stepId,
content: action.content,
parentContent: action.parentContent,
}
}
}
Loading…
Cancel
Save