add visualizer

pull/119/head
maxime 1 year ago
parent 9e8606184c
commit 15b47f354e

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

@ -0,0 +1,224 @@
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) {
console.log(content, parentContent)
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,15 @@
import { ReactElement, ReactNode, RefObject } from "react" import { ReactElement, ReactNode, RefObject, useEffect, useState } from "react"
import { Action } from "../../model/tactic/Action" import { Action } from "../../model/tactic/Action"
import { CourtAction } from "./CourtAction" import { CourtAction } from "./CourtAction"
import { ComponentId, TacticComponent } from "../../model/tactic/Tactic" import { ComponentId, 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 { export interface BasketCourtProps {
components: TacticComponent[] components: TacticComponent[]
parentComponents: TacticComponent[] | null parentComponents: TacticComponent[] | null
previewAction: ActionPreview | null previewAction?: ActionPreview | null
renderComponent: (comp: TacticComponent, isFromParent: boolean) => ReactNode renderComponent: (comp: TacticComponent, isFromParent: boolean) => ReactNode
renderActions: (comp: TacticComponent, isFromParent: boolean) => ReactNode[] renderActions: (comp: TacticComponent, isFromParent: boolean) => ReactNode[]
@ -32,6 +34,14 @@ export function BasketCourt({
courtImage, courtImage,
courtRef, courtRef,
}: BasketCourtProps) { }: BasketCourtProps) {
const [court, setCourt] = useState(courtRef.current)
//force update once the court reference is set
useEffect(() => {
setCourt(courtRef.current)
}, [courtRef])
return ( return (
<div <div
className="court-container" className="court-container"
@ -39,14 +49,14 @@ export function BasketCourt({
style={{ position: "relative" }}> style={{ position: "relative" }}>
{courtImage} {courtImage}
{courtRef.current && {court &&
parentComponents?.map((i) => renderComponent(i, true))} parentComponents?.map((i) => renderComponent(i, true))}
{courtRef.current && {court &&
parentComponents?.flatMap((i) => renderActions(i, true))} parentComponents?.flatMap((i) => renderActions(i, true))}
{courtRef.current && {court &&
components.map((i) => renderComponent(i, false))} components.map((i) => renderComponent(i, false))}
{courtRef.current && {court &&
components.flatMap((i) => renderActions(i, false))} components.flatMap((i) => renderActions(i, false))}
{previewAction && ( {previewAction && (
@ -64,3 +74,15 @@ export function BasketCourt({
</div> </div>
) )
} }
export function Court({ courtType }: { courtType: string }) {
return (
<div className="court-image-div">
{courtType == "PLAIN" ? (
<PlainCourt className="court-image" />
) : (
<HalfCourt className="court-image" />
)}
</div>
)
}

@ -20,27 +20,6 @@ export function CourtBall({
}: EditableCourtBallProps) { }: EditableCourtBallProps) {
const pieceRef = useRef<HTMLDivElement>(null) const pieceRef = useRef<HTMLDivElement>(null)
function courtBallPiece(
{ x, y }: Pos,
pieceRef?: RefObject<HTMLDivElement>,
onKeyUp?: KeyboardEventHandler,
) {
return (
<div
className={"ball-div"}
ref={pieceRef}
tabIndex={0}
onKeyUp={onKeyUp}
style={{
position: "absolute",
left: `${x * 100}%`,
top: `${y * 100}%`,
}}>
<BallPiece />
</div>
)
}
if (ball.frozen) { if (ball.frozen) {
return courtBallPiece(ball.pos) return courtBallPiece(ball.pos)
} }
@ -58,3 +37,32 @@ export function CourtBall({
</Draggable> </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"}
ref={pieceRef}
tabIndex={0}
onKeyUp={onKeyUp}
style={{
position: "absolute",
left: `${x * 100}%`,
top: `${y * 100}%`,
}}>
<BallPiece />
</div>
)
}

@ -4,14 +4,14 @@ import BendableArrow from "../arrows/BendableArrow"
import { ReactNode, useMemo, useRef } from "react" import { ReactNode, useMemo, useRef } from "react"
import AddSvg from "../../assets/icon/add.svg?react" import AddSvg from "../../assets/icon/add.svg?react"
import RemoveSvg from "../../assets/icon/remove.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 { export interface StepsTreeProps {
root: StepInfoNode root: StepInfoNode
selectedStepId: number selectedStepId: number
onAddChildren: (parent: StepInfoNode) => void onAddChildren?: (parent: StepInfoNode) => void
onRemoveNode: (node: StepInfoNode) => void onRemoveNode?: (node: StepInfoNode) => void
onStepSelected: (node: StepInfoNode) => void onStepSelected?: (node: StepInfoNode) => void
} }
export default function StepsTree({ export default function StepsTree({
@ -40,9 +40,9 @@ interface StepsTreeContentProps {
rootNode: StepInfoNode rootNode: StepInfoNode
selectedStepId: number selectedStepId: number
onAddChildren: (parent: StepInfoNode) => void onAddChildren?: (parent: StepInfoNode) => void
onRemoveNode: (node: StepInfoNode) => void onRemoveNode?: (node: StepInfoNode) => void
onStepSelected: (node: StepInfoNode) => void onStepSelected?: (node: StepInfoNode) => void
} }
function StepsTreeNode({ function StepsTreeNode({
@ -79,13 +79,19 @@ function StepsTreeNode({
<StepPiece <StepPiece
id={node.id} id={node.id}
isSelected={selectedStepId === node.id} isSelected={selectedStepId === node.id}
onAddButtonClicked={() => onAddChildren(node)} onAddButtonClicked={() => {
if (onAddChildren) onAddChildren(node)
}}
onRemoveButtonClicked={ onRemoveButtonClicked={
rootNode.id === node.id rootNode.id === node.id
? undefined ? undefined
: () => onRemoveNode(node) : () => {
if (onRemoveNode) onRemoveNode(node)
}
} }
onSelected={() => onStepSelected(node)}> onSelected={() => {
if (onStepSelected) onStepSelected(node)
}}>
<p> <p>
{useMemo( {useMemo(
() => getStepName(rootNode, node.id), () => getStepName(rootNode, node.id),

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

@ -1,21 +1,22 @@
import { import {
BallState, BallState,
Player, Player,
PlayerInfo,
PlayerLike, PlayerLike,
PlayerPhantom, PlayerPhantom,
} from "../model/tactic/Player" } from "../model/tactic/Player.ts"
import { import {
ComponentId, ComponentId,
StepContent, StepContent,
TacticComponent, TacticComponent,
} from "../model/tactic/Tactic" } from "../model/tactic/Tactic.ts"
import { removeComponent, updateComponent } from "./TacticContentDomains" import { removeComponent, updateComponent } from "./TacticContentDomains.ts"
import { import {
removeAllActionsTargeting, removeAllActionsTargeting,
spreadNewStateFromOriginStateChange, spreadNewStateFromOriginStateChange,
} from "./ActionsDomains" } from "./ActionsDomains.ts"
import { ActionKind } from "../model/tactic/Action" import { ActionKind } from "../model/tactic/Action.ts"
import { import {
add, add,
minus, minus,
@ -311,7 +312,7 @@ export function truncatePlayerPath(
for (let i = truncateStartIdx; i < path.items.length; i++) { for (let i = truncateStartIdx; i < path.items.length; i++) {
const pathPhantomId = path.items[i] const pathPhantomId = path.items[i]
//remove the phantom from the tactic //remove the phantom from the domains
content = removeComponent(pathPhantomId, content) content = removeComponent(pathPhantomId, content)
content = removeAllActionsTargeting(pathPhantomId, content) content = removeAllActionsTargeting(pathPhantomId, content)
} }
@ -330,3 +331,46 @@ export function truncatePlayerPath(
content, 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( export function addStepNode(
root: StepInfoNode, root: StepInfoNode,

@ -1,4 +1,4 @@
import { equals, Pos, ratioWithinBase } from "../geo/Pos" import { equals, Pos, ratioWithinBase } from "../geo/Pos.ts"
import { import {
BallState, BallState,
@ -7,28 +7,28 @@ import {
PlayerLike, PlayerLike,
PlayerPhantom, PlayerPhantom,
PlayerTeam, PlayerTeam,
} from "../model/tactic/Player" } from "../model/tactic/Player.ts"
import { import {
Ball, Ball,
BALL_ID, BALL_ID,
BALL_TYPE, BALL_TYPE,
CourtObject, CourtObject,
} from "../model/tactic/CourtObjects" } from "../model/tactic/CourtObjects.ts"
import { import {
ComponentId, ComponentId,
StepContent, StepContent,
TacticComponent, TacticComponent,
} from "../model/tactic/Tactic" } from "../model/tactic/Tactic.ts"
import { overlaps } from "../geo/Box" import { overlaps } from "../geo/Box.ts"
import { RackedCourtObject, RackedPlayer } from "./RackedItems" import { RackedCourtObject, RackedPlayer } from "../editor/RackedItems.ts"
import { import {
getComponent, getComponent,
getOrigin, getOrigin,
getPrecomputedPosition, getPrecomputedPosition,
removePlayer, removePlayer,
tryGetComponent, tryGetComponent,
} from "./PlayerDomains" } from "./PlayerDomains.ts"
import { Action, ActionKind } from "../model/tactic/Action.ts" import { Action, ActionKind } from "../model/tactic/Action.ts"
import { spreadNewStateFromOriginStateChange } from "./ActionsDomains.ts" import { spreadNewStateFromOriginStateChange } from "./ActionsDomains.ts"

@ -1,5 +1,6 @@
import { import {
CSSProperties, CSSProperties,
ReactNode,
RefObject, RefObject,
SetStateAction, SetStateAction,
useCallback, useCallback,
@ -10,8 +11,6 @@ import {
} from "react" } from "react"
import "../style/editor.css" import "../style/editor.css"
import TitleInput from "../components/TitleInput" 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" import { BallPiece } from "../components/editor/BallPiece"
@ -19,7 +18,6 @@ import { Rack } from "../components/Rack"
import { PlayerPiece } from "../components/editor/PlayerPiece" import { PlayerPiece } from "../components/editor/PlayerPiece"
import { import {
ComponentId,
CourtType, CourtType,
StepContent, StepContent,
StepInfoNode, StepInfoNode,
@ -33,7 +31,11 @@ import SavingState, {
import { BALL_TYPE } from "../model/tactic/CourtObjects" import { BALL_TYPE } from "../model/tactic/CourtObjects"
import { CourtAction } from "../components/editor/CourtAction" import { CourtAction } from "../components/editor/CourtAction"
import { ActionPreview, BasketCourt } from "../components/editor/BasketCourt" import {
ActionPreview,
BasketCourt,
Court,
} from "../components/editor/BasketCourt"
import { overlaps } from "../geo/Box" import { overlaps } from "../geo/Box"
import { import {
@ -50,7 +52,7 @@ import {
removeBall, removeBall,
selectContent, selectContent,
updateComponent, updateComponent,
} from "../editor/TacticContentDomains" } from "../domains/TacticContentDomains.ts"
import { import {
BallState, BallState,
@ -71,16 +73,18 @@ import {
isActionValid, isActionValid,
removeAction, removeAction,
spreadNewStateFromOriginStateChange, spreadNewStateFromOriginStateChange,
} from "../editor/ActionsDomains" } from "../domains/ActionsDomains.ts"
import ArrowAction from "../components/actions/ArrowAction" import ArrowAction from "../components/actions/ArrowAction"
import { middlePos, Pos, ratioWithinBase } from "../geo/Pos" import { middlePos, Pos, ratioWithinBase } from "../geo/Pos"
import { Action, ActionKind } from "../model/tactic/Action" import { Action, ActionKind } from "../model/tactic/Action"
import BallAction from "../components/actions/BallAction" import BallAction from "../components/actions/BallAction"
import { import {
computePhantomPositioning, ComputedRelativePositions,
computeRelativePositions,
getOrigin, getOrigin,
getPhantomInfo,
removePlayer, removePlayer,
} from "../editor/PlayerDomains" } from "../domains/PlayerDomains.ts"
import { CourtBall } from "../components/editor/CourtBall" import { CourtBall } from "../components/editor/CourtBall"
import StepsTree from "../components/editor/StepsTree" import StepsTree from "../components/editor/StepsTree"
import { import {
@ -88,12 +92,15 @@ import {
getParent, getParent,
getStepNode, getStepNode,
removeStepNode, removeStepNode,
} from "../editor/StepsDomain" } from "../domains/StepsDomain.ts"
import SplitLayout from "../components/SplitLayout.tsx" 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 { LocalStorageTacticService } from "../service/LocalStorageTacticService.ts"
import { APITacticService } from "../service/APITacticService.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 { ContentVersions } from "../editor/ContentVersions.ts"
import { useAppFetcher } from "../App.tsx" import { useAppFetcher } from "../App.tsx"
@ -101,21 +108,15 @@ const ERROR_STYLE: CSSProperties = {
borderColor: "red", borderColor: "red",
} }
type ComputedRelativePositions = Map<ComponentId, Pos>
type ComputedStepContent = { type ComputedStepContent = {
content: StepContent content: StepContent
relativePositions: ComputedRelativePositions relativePositions: ComputedRelativePositions
} }
export interface EditorPageProps { export interface EditorProps {
guestMode: boolean guestMode: boolean
} }
export default function Editor({ guestMode }: EditorPageProps) {
return <EditorPortal guestMode={guestMode} />
}
interface EditorService { interface EditorService {
addStep( addStep(
parent: StepInfoNode, parent: StepInfoNode,
@ -129,28 +130,47 @@ interface EditorService {
setContent(content: SetStateAction<StepContent>): void setContent(content: SetStateAction<StepContent>): void
setName(name: string): Promise<SaveState> setName(name: string): Promise<SaveState>
openVisualizer(): Promise<void>
} }
function EditorPortal({ guestMode }: EditorPageProps) { export default function Editor({ guestMode }: EditorProps) {
const { tacticId: idStr } = useParams() const { tacticId: idStr } = useParams()
const fetcher = useAppFetcher() const fetcher = useAppFetcher()
const navigate = useNavigate()
if (guestMode || !idStr) { if (guestMode || !idStr) {
return <EditorPageWrapper service={LocalStorageTacticService.init()} /> return (
<EditorPageWrapper
service={LocalStorageTacticService.init()}
openVisualizer={() => navigate("/tactic/view-guest")}
/>
)
} }
return ( return (
<EditorPageWrapper <EditorPageWrapper
service={new APITacticService(fetcher, parseInt(idStr))} 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 [panicMessage, setPanicMessage] = useState<string>()
const [stepId, setStepId] = useState<number>() const [stepId, setStepId] = useState<number>()
const [tacticName, setTacticName] = useState<string>() const [tacticName, setTacticName] = useState<string>()
const [courtType, setCourtType] = useState<CourtType>() const [courtType, setCourtType] = useState<CourtType>()
const [stepsTree, setStepsTree] = useState<StepInfoNode>() const [stepsTree, setStepsTree] = useState<StepInfoNode>()
const [parentContent, setParentContent] = useState<StepContent | null>(null)
const courtRef = useRef<HTMLDivElement>(null) const courtRef = useRef<HTMLDivElement>(null)
@ -209,8 +229,6 @@ function EditorPageWrapper({ service }: { service: TacticService }) {
[stepsVersions, service, stepId, stepsTree], [stepsVersions, service, stepId, stepsTree],
) )
const [parentContent, setParentContent] = useState<StepContent | null>(null)
const [stepContent, setStepContent, saveState] = const [stepContent, setStepContent, saveState] =
useContentState<StepContent>( useContentState<StepContent>(
{ components: [] }, { components: [] },
@ -334,8 +352,12 @@ function EditorPageWrapper({ service }: { service: TacticService }) {
setStepId(step) setStepId(step)
setStepContent(result, false) setStepContent(result, false)
}, },
async openVisualizer(): Promise<void> {
openVisualizer()
},
} }
}, [stepsVersions, service, setStepContent, stepsTree]) }, [stepsTree, service, stepsVersions, setStepContent, openVisualizer])
if (panicMessage) { if (panicMessage) {
return <p>{panicMessage}</p> return <p>{panicMessage}</p>
@ -543,6 +565,7 @@ function EditorPage({
content, content,
courtRef, courtRef,
doMoveBall, doMoveBall,
parentContent,
previewAction?.isInvalid, previewAction?.isInvalid,
service.setContent, service.setContent,
], ],
@ -558,22 +581,14 @@ function EditorPage({
const usedContent = isFromParent ? parentContent! : content const usedContent = isFromParent ? parentContent! : content
if (isPhantom) { if (isPhantom) {
const origin = getOrigin(component, usedContent.components) info = getPhantomInfo(
info = { component,
id: component.id, usedContent,
team: origin.team, relativePositions,
role: origin.role, courtBounds(),
pos: computePhantomPositioning( )
component,
usedContent,
relativePositions,
courtBounds(),
),
ballState: component.ballState,
}
} else { } else {
info = component info = component
forceFreeze ||= component.frozen forceFreeze ||= component.frozen
} }
@ -612,8 +627,9 @@ function EditorPage({
) )
}, },
[ [
courtRef, parentContent,
content, content,
courtRef,
relativePositions, relativePositions,
courtBounds, courtBounds,
renderAvailablePlayerActions, renderAvailablePlayerActions,
@ -649,7 +665,7 @@ function EditorPage({
) )
const renderComponent = useCallback( const renderComponent = useCallback(
(component: TacticComponent, isFromParent: boolean) => { (component: TacticComponent, isFromParent: boolean): ReactNode => {
if (component.type === "player" || component.type === "phantom") { if (component.type === "player" || component.type === "phantom") {
return renderPlayer(component, isFromParent) return renderPlayer(component, isFromParent)
} }
@ -669,7 +685,7 @@ function EditorPage({
/> />
) )
} }
throw new Error("unknown tactic component " + component) return <></>
}, },
[service, renderPlayer, doMoveBall], [service, renderPlayer, doMoveBall],
) )
@ -799,25 +815,26 @@ function EditorPage({
<div id="topbar-left"> <div id="topbar-left">
<SavingState state={contentSaveState} /> <SavingState state={contentSaveState} />
</div> </div>
<div id="title-input-div"> <TitleInput
<TitleInput style={titleStyle}
style={titleStyle} default_value={name}
default_value={name} onValidated={useCallback(
onValidated={useCallback( (new_name) => {
(new_name) => { service.setName(new_name).then((state) => {
service.setName(new_name).then((state) => { setTitleStyle(
setTitleStyle( state == SaveStates.Ok ? {} : ERROR_STYLE,
state == SaveStates.Ok )
? {} })
: ERROR_STYLE, },
) [service],
}) )}
}, />
[service],
)}
/>
</div>
<div id="topbar-right"> <div id="topbar-right">
<button
id="toggle-visualisation"
onClick={service.openVisualizer}>
VISUALISER
</button>
<button <button
id={"show-steps-button"} id={"show-steps-button"}
onClick={() => setStepsTreeVisible((b) => !b)}> onClick={() => setStepsTreeVisible((b) => !b)}>
@ -1060,18 +1077,6 @@ function renderCourtObject(courtObject: RackedCourtObject) {
throw new Error("unknown racked court object " + courtObject.key) 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>( function debounceAsync<A, B>(
f: (args: A) => Promise<B>, f: (args: A) => Promise<B>,
delay = 1000, delay = 1000,
@ -1119,22 +1124,6 @@ function useContentState<S>(
return [content, setContentSynced, savingState] return [content, setContentSynced, savingState]
} }
function computeRelativePositions(courtBounds: DOMRect, content: StepContent) {
const relativePositionsCache: ComputedRelativePositions = new Map()
for (const component of content.components) {
if (component.type !== "phantom") continue
computePhantomPositioning(
component,
content,
relativePositionsCache,
courtBounds,
)
}
return relativePositionsCache
}
async function updateStepContents( async function updateStepContents(
stepId: number, stepId: number,
stepsTree: StepInfoNode, stepsTree: StepInfoNode,

@ -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,178 @@
import { ServiceError, TacticService } from "../service/MutableTacticService.ts"
import { useParams } from "react-router-dom"
import { useCallback, useEffect, useMemo, useReducer, useState } from "react"
import { VisualizerState, VisualizerStateActionKind, visualizerStateReducer } 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 fetcher = useAppFetcher()
if (guestMode || !idStr) {
return (
<ServedVisualizerPage service={LocalStorageTacticService.init()} />
)
}
return (
<ServedVisualizerPage service={new APITacticService(fetcher, parseInt(idStr))} />
)
}
interface VisualizerService {
selectStep(step: number): Promise<void | ServiceError>
}
function ServedVisualizerPage({ service }: { service: TacticService }) {
const [panicMessage, setPanicMessage] = useState<string>()
const [state, dispatch] = useReducer(visualizerStateReducer, null)
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
}
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,
})
},
}),
[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} />
}
interface VisualizerPageContentProps {
state: VisualizerState
service: VisualizerService
}
function VisualizerPageContent({
state: { content, parentContent, stepId, stepsTree, courtType, tacticName },
service,
}: VisualizerPageContentProps) {
const [isStepsTreeVisible, setStepsTreeVisible] = useState(false)
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="topbar-div">
<p id="title">{tacticName}</p>
<button
id={"show-steps-button"}
onClick={() => setStepsTreeVisible((b) => !b)}>
ETAPES
</button>
</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 { StepContent, StepInfoNode } from "../model/tactic/Tactic.ts"
import { Fetcher } from "../app/Fetcher.ts" import { Fetcher } from "../app/Fetcher.ts"
export class APITacticService implements TacticService { export class APITacticService implements MutableTacticService {
private readonly tacticId: number private readonly tacticId: number
private readonly fetcher: Fetcher private readonly fetcher: Fetcher

@ -1,16 +1,20 @@
import { TacticService, ServiceError, TacticContext } from "./TacticService.ts" import {
MutableTacticService,
ServiceError,
TacticContext,
} from "./MutableTacticService.ts"
import { StepContent, StepInfoNode } from "../model/tactic/Tactic.ts" import { StepContent, StepInfoNode } from "../model/tactic/Tactic.ts"
import { import {
addStepNode, addStepNode,
getAvailableId, getAvailableId,
removeStepNode, removeStepNode,
} from "../editor/StepsDomain.ts" } from "../domains/StepsDomain.ts"
const GUEST_MODE_STEP_CONTENT_STORAGE_KEY = "guest_mode_step" 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_STEP_ROOT_NODE_INFO_STORAGE_KEY = "guest_mode_step_tree"
const GUEST_MODE_TITLE_STORAGE_KEY = "guest_mode_title" const GUEST_MODE_TITLE_STORAGE_KEY = "guest_mode_title"
export class LocalStorageTacticService implements TacticService { export class LocalStorageTacticService implements MutableTacticService {
private constructor() {} private constructor() {}
static init(): LocalStorageTacticService { static init(): LocalStorageTacticService {

@ -13,7 +13,10 @@ export enum ServiceError {
export interface TacticService { export interface TacticService {
getContext(): Promise<TacticContext | ServiceError> getContext(): Promise<TacticContext | ServiceError>
getContent(step: number): Promise<StepContent | ServiceError>
}
export interface MutableTacticService extends TacticService {
addStep( addStep(
parent: StepInfoNode, parent: StepInfoNode,
content: StepContent, content: StepContent,
@ -27,6 +30,4 @@ export interface TacticService {
step: number, step: number,
content: StepContent, content: StepContent,
): Promise<void | ServiceError> ): 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 "theme/default.css";
@import "court.css";
@import "tactic.css";
#main-div { #main-div {
display: flex; display: flex;
@ -41,7 +43,6 @@
.title-input { .title-input {
width: 25ch; width: 25ch;
align-self: center;
} }
#editor-div { #editor-div {
@ -58,6 +59,15 @@
#content-div { #content-div {
overflow: hidden; overflow: hidden;
display: flex;
flex-direction: column;
}
#racks {
display: flex;
align-content: space-between;
justify-content: space-between;
width: 100%;
} }
.curtain { .curtain {
@ -94,14 +104,6 @@
margin-left: 5px; margin-left: 5px;
} }
.player-piece.opponents {
background-color: var(--player-opponents-color);
}
.player-piece.allies {
background-color: var(--player-allies-color);
}
#court-div { #court-div {
background-color: var(--background-color); background-color: var(--background-color);
@ -114,35 +116,6 @@
align-content: center; 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 { .save-state {
display: flex; display: flex;
align-items: center; align-items: center;

@ -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 { @import "court.css";
height: 100vh; @import "theme/default.css";
width: 100%; @import "player.css";
@import "tactic.css";
#visualizer {
display: flex; display: flex;
height: 100%;
width: 100%;
background-color: var(--background-color);
flex-direction: column; flex-direction: column;
overflow: hidden;
} }
#topbar { #editor-div {
height: 100%;
}
.curtain {
width: 100%;
height: 100%;
}
#content-div {
display: flex; display: flex;
background-color: var(--main-color);
justify-content: center; justify-content: center;
align-items: center; align-items: center;
width: 100%;
height: 100%;
} }
h1 { #topbar-div {
position: relative;
width: 100%;
display: flex;
background-color: var(--main-color);
align-content: flex-end;
justify-content: flex-end;
}
#show-steps-button {
user-select: none;
align-self: flex-end;
height: 100%;
}
#title {
align-self: center;
margin: 0;
user-select: none;
position: absolute;
width: 100%;
text-align: center; text-align: center;
margin-top: 0; pointer-events: none;
} }
#court-container { #topbar {
flex: 1;
display: flex; display: flex;
justify-content: center;
background-color: var(--main-color); background-color: var(--main-color);
justify-content: center;
align-items: center;
} }
#court { #steps-div {
max-width: 80%; background-color: var(--editor-tree-background);
max-height: 80%; overflow: scroll;
height: 100%;
} }

@ -0,0 +1,45 @@
import { CourtType, StepContent, StepInfoNode } from "../model/tactic/Tactic.ts"
export interface VisualizerState {
stepId: number
tacticName: string
courtType: CourtType
stepsTree: StepInfoNode
content: StepContent
parentContent: StepContent | null
}
export 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 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