spread changes of step content to its direct children
continuous-integration/drone/push Build is passing Details

maxime.batista 1 year ago committed by maxime
parent 293d21c162
commit cf89782153

@ -7,15 +7,14 @@ import {
import { ratioWithinBase } from "../geo/Pos" import { ratioWithinBase } from "../geo/Pos"
import { import {
ComponentId, ComponentId,
TacticComponent,
StepContent, StepContent,
TacticComponent,
} from "../model/tactic/Tactic" } from "../model/tactic/Tactic"
import { overlaps } from "../geo/Box" import { overlaps } from "../geo/Box"
import { Action, ActionKind, moves } from "../model/tactic/Action" import { Action, ActionKind, moves } from "../model/tactic/Action"
import { removeBall, updateComponent } from "./TacticContentDomains" import { removeBall, updateComponent } from "./TacticContentDomains"
import { import {
areInSamePath, areInSamePath,
changePlayerBallState,
getComponent, getComponent,
getOrigin, getOrigin,
getPlayerNextTo, getPlayerNextTo,
@ -411,20 +410,27 @@ export function removeAction(
(origin.type === "player" || origin.type === "phantom") (origin.type === "player" || origin.type === "phantom")
) { ) {
if (target.type === "player" || target.type === "phantom") if (target.type === "player" || target.type === "phantom")
content = changePlayerBallState(target, BallState.NONE, content) content =
spreadNewStateFromOriginStateChange(
target,
BallState.NONE,
content,
) ?? content
if (origin.ballState === BallState.PASSED) { if (origin.ballState === BallState.PASSED) {
content = changePlayerBallState( content =
origin, spreadNewStateFromOriginStateChange(
BallState.HOLDS_BY_PASS, origin,
content, BallState.HOLDS_BY_PASS,
) content,
) ?? content
} else if (origin.ballState === BallState.PASSED_ORIGIN) { } else if (origin.ballState === BallState.PASSED_ORIGIN) {
content = changePlayerBallState( content =
origin, spreadNewStateFromOriginStateChange(
BallState.HOLDS_ORIGIN, origin,
content, BallState.HOLDS_ORIGIN,
) content,
) ?? content
} }
} }
@ -456,6 +462,7 @@ export function removeAction(
/** /**
* Spreads the changes to others actions and components, directly or indirectly bound to the origin, implied by the change of the origin's actual state with * Spreads the changes to others actions and components, directly or indirectly bound to the origin, implied by the change of the origin's actual state with
* the given newState. * the given newState.
* @returns the new state if it has been updated, or null if no changes were operated
* @param origin * @param origin
* @param newState * @param newState
* @param content * @param content
@ -464,9 +471,9 @@ export function spreadNewStateFromOriginStateChange(
origin: PlayerLike, origin: PlayerLike,
newState: BallState, newState: BallState,
content: StepContent, content: StepContent,
): StepContent { ): StepContent | null {
if (origin.ballState === newState) { if (origin.ballState === newState) {
return content return null
} }
origin = { origin = {
@ -552,11 +559,12 @@ export function spreadNewStateFromOriginStateChange(
content = updateComponent(origin, content) content = updateComponent(origin, content)
} }
content = spreadNewStateFromOriginStateChange( content =
actionTarget, spreadNewStateFromOriginStateChange(
targetState, actionTarget,
content, targetState,
) content,
) ?? content
} }
return content return content

@ -55,11 +55,23 @@ export function getPlayerNextTo(
: getComponent<PlayerLike>(pathItems[targetIdx - 1], components) : getComponent<PlayerLike>(pathItems[targetIdx - 1], components)
} }
//FIXME this function can be a bottleneck if the phantom's position is export function getPrecomputedPosition(
// following another phantom and / or the origin of the phantom is another phantom: PlayerPhantom,
computedPositions: Map<string, Pos>,
): Pos | undefined {
const positioning = phantom.pos
// If the position is already known and fixed, return the pos
if (positioning.type === "fixed") return positioning
return computedPositions.get(phantom.id)
}
export function computePhantomPositioning( export function computePhantomPositioning(
phantom: PlayerPhantom, phantom: PlayerPhantom,
content: StepContent, content: StepContent,
computedPositions: Map<string, Pos>,
area: DOMRect, area: DOMRect,
): Pos { ): Pos {
const positioning = phantom.pos const positioning = phantom.pos
@ -67,6 +79,9 @@ export function computePhantomPositioning(
// If the position is already known and fixed, return the pos // If the position is already known and fixed, return the pos
if (positioning.type === "fixed") return positioning if (positioning.type === "fixed") return positioning
const storedPos = computedPositions.get(phantom.id)
if (storedPos) return storedPos
// If the position is to determine (positioning.type = "follows"), determine the phantom's pos // If the position is to determine (positioning.type = "follows"), determine the phantom's pos
// by calculating it from the referent position, and the action that targets the referent. // by calculating it from the referent position, and the action that targets the referent.
@ -77,7 +92,12 @@ export function computePhantomPositioning(
const referentPos = const referentPos =
referent.type === "player" referent.type === "player"
? referent.pos ? referent.pos
: computePhantomPositioning(referent, content, area) : computePhantomPositioning(
referent,
content,
computedPositions,
area,
)
// Get the origin // Get the origin
const origin = getOrigin(phantom, components) const origin = getOrigin(phantom, components)
@ -109,10 +129,11 @@ export function computePhantomPositioning(
pivotPoint = pivotPoint =
playerBeforePhantom.type === "phantom" playerBeforePhantom.type === "phantom"
? computePhantomPositioning( ? computePhantomPositioning(
playerBeforePhantom, playerBeforePhantom,
content, content,
area, computedPositions,
) area,
)
: playerBeforePhantom.pos : playerBeforePhantom.pos
} }
} }
@ -126,14 +147,23 @@ export function computePhantomPositioning(
}) })
const segmentProjectionRatio: Pos = ratioWithinBase(segmentProjection, area) const segmentProjectionRatio: Pos = ratioWithinBase(segmentProjection, area)
return add(referentPos, segmentProjectionRatio) const result = add(referentPos, segmentProjectionRatio)
computedPositions.set(phantom.id, result)
return result
} }
export function getComponent<T extends TacticComponent>( export function getComponent<T extends TacticComponent>(
id: string, id: string,
components: TacticComponent[], components: TacticComponent[],
): T { ): T {
return components.find((c) => c.id === id)! as T return tryGetComponent<T>(id, components)!
}
export function tryGetComponent<T extends TacticComponent>(
id: string,
components: TacticComponent[],
): T | undefined {
return components.find((c) => c.id === id) as T
} }
export function areInSamePath(a: PlayerLike, b: PlayerLike) { export function areInSamePath(a: PlayerLike, b: PlayerLike) {
@ -254,10 +284,12 @@ export function removePlayer(
const actionTarget = content.components.find( const actionTarget = content.components.find(
(c) => c.id === action.target, (c) => c.id === action.target,
)! as PlayerLike )! as PlayerLike
return spreadNewStateFromOriginStateChange( return (
actionTarget, spreadNewStateFromOriginStateChange(
BallState.NONE, actionTarget,
content, BallState.NONE,
content,
) ?? content
) )
} }
@ -297,11 +329,3 @@ export function truncatePlayerPath(
content, content,
) )
} }
export function changePlayerBallState(
player: PlayerLike,
newState: BallState,
content: StepContent,
): StepContent {
return spreadNewStateFromOriginStateChange(player, newState, content)
}

@ -18,12 +18,24 @@ export function addStepNode(
} }
} }
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( export function removeStepNode(
root: StepInfoNode, root: StepInfoNode,
node: StepInfoNode, node: StepInfoNode,
): StepInfoNode | null { ): StepInfoNode | undefined {
if (root.id === node.id) { if (root.id === node.id) {
return null return undefined
} }
return { return {

@ -1,4 +1,4 @@
import { Pos, ratioWithinBase } from "../geo/Pos" import { equals, Pos, ratioWithinBase } from "../geo/Pos"
import { import {
BallState, BallState,
@ -23,12 +23,13 @@ import {
import { overlaps } from "../geo/Box" import { overlaps } from "../geo/Box"
import { RackedCourtObject, RackedPlayer } from "./RackedItems" import { RackedCourtObject, RackedPlayer } from "./RackedItems"
import { import {
changePlayerBallState,
computePhantomPositioning,
getComponent, getComponent,
getOrigin, getOrigin,
getPrecomputedPosition,
tryGetComponent,
} from "./PlayerDomains" } from "./PlayerDomains"
import { ActionKind } from "../model/tactic/Action.ts" import { ActionKind } from "../model/tactic/Action.ts"
import { spreadNewStateFromOriginStateChange } from "./ActionsDomains.ts"
export function placePlayerAt( export function placePlayerAt(
refBounds: DOMRect, refBounds: DOMRect,
@ -103,7 +104,9 @@ export function dropBallOnComponent(
? BallState.HOLDS_ORIGIN ? BallState.HOLDS_ORIGIN
: BallState.HOLDS_BY_PASS : BallState.HOLDS_BY_PASS
content = changePlayerBallState(component, newState, content) content =
spreadNewStateFromOriginStateChange(component, newState, content) ??
content
} }
return removeBall(content) return removeBall(content)
@ -194,9 +197,9 @@ export function moveComponent(
phantomIdx == 0 phantomIdx == 0
? origin ? origin
: getComponent( : getComponent(
originPathItems[phantomIdx - 1], originPathItems[phantomIdx - 1],
content.components, content.components,
) )
// detach the action from the screen target and transform it to a regular move action to the phantom. // detach the action from the screen target and transform it to a regular move action to the phantom.
content = updateComponent( content = updateComponent(
{ {
@ -204,18 +207,18 @@ export function moveComponent(
actions: playerBeforePhantom.actions.map((a) => actions: playerBeforePhantom.actions.map((a) =>
a.target === referent a.target === referent
? { ? {
...a, ...a,
segments: a.segments.toSpliced( segments: a.segments.toSpliced(
a.segments.length - 2, a.segments.length - 2,
1, 1,
{ {
...a.segments[a.segments.length - 1], ...a.segments[a.segments.length - 1],
next: component.id, next: component.id,
}, },
), ),
target: component.id, target: component.id,
type: ActionKind.MOVE, type: ActionKind.MOVE,
} }
: a, : a,
), ),
}, },
@ -228,9 +231,9 @@ export function moveComponent(
...component, ...component,
pos: isPhantom pos: isPhantom
? { ? {
type: "fixed", type: "fixed",
...newPos, ...newPos,
} }
: newPos, : newPos,
}, },
content, content,
@ -299,21 +302,21 @@ export function getRackPlayers(
/** /**
* Returns a step content that only contains the terminal state of each components inside the given content * Returns a step content that only contains the terminal state of each components inside the given content
* @param content * @param content
* @param courtArea * @param computedPositions
*/ */
export function getTerminalState( export function computeTerminalState(
content: StepContent, content: StepContent,
courtArea: DOMRect, computedPositions: Map<string, Pos>,
): StepContent { ): StepContent {
const nonPhantomComponents: (Player | CourtObject)[] = const nonPhantomComponents: (Player | CourtObject)[] =
content.components.filter((c) => c.type !== "phantom") as ( content.components.filter((c) => c.type !== "phantom") as (
| Player | Player
| CourtObject | CourtObject
)[] )[]
const componentsTargetedState = nonPhantomComponents.map((comp) => const componentsTargetedState = nonPhantomComponents.map((comp) =>
comp.type === "player" comp.type === "player"
? getPlayerTerminalState(comp, content, courtArea) ? getPlayerTerminalState(comp, content, computedPositions)
: comp, : comp,
) )
@ -325,7 +328,7 @@ export function getTerminalState(
function getPlayerTerminalState( function getPlayerTerminalState(
player: Player, player: Player,
content: StepContent, content: StepContent,
area: DOMRect, computedPositions: Map<string, Pos>,
): Player { ): Player {
function stateAfter(state: BallState): BallState { function stateAfter(state: BallState): BallState {
switch (state) { switch (state) {
@ -342,9 +345,15 @@ function getPlayerTerminalState(
} }
function getTerminalPos(component: PlayerLike): Pos { function getTerminalPos(component: PlayerLike): Pos {
return component.type === "phantom" if (component.type === "phantom") {
? computePhantomPositioning(component, content, area) const pos = getPrecomputedPosition(component, computedPositions)
: component.pos 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 const phantoms = player.path?.items
@ -377,3 +386,50 @@ function getPlayerTerminalState(
pos, pos,
} }
} }
export function drainTerminalStateOnChildContent(
parentTerminalState: StepContent,
childContent: StepContent,
): StepContent | null {
let gotUpdated = false
for (const parentComponent of parentTerminalState.components) {
const 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
}
// also update the position of the player if it has been moved
if (!equals(childComponent.pos, parentComponent.pos)) {
gotUpdated = true
childContent = updateComponent({
...childComponent,
pos: parentComponent.pos,
}, childContent)
}
}
return gotUpdated ? childContent : null
}

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

@ -71,6 +71,8 @@ export type PhantomPositioning =
| FixedPhantomPositioning | FixedPhantomPositioning
| FollowsPhantomPositioning | FollowsPhantomPositioning
/** /**
* A player phantom is a kind of component that represents the future state of a player * A player phantom is a kind of component that represents the future state of a player
* according to the court's step information * according to the court's step information

@ -20,6 +20,7 @@ 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,
@ -39,10 +40,11 @@ import { ActionPreview, BasketCourt } from "../components/editor/BasketCourt"
import { overlaps } from "../geo/Box" import { overlaps } from "../geo/Box"
import { import {
computeTerminalState,
drainTerminalStateOnChildContent,
dropBallOnComponent, dropBallOnComponent,
getComponentCollided, getComponentCollided,
getRackPlayers, getRackPlayers,
getTerminalState,
moveComponent, moveComponent,
placeBallAt, placeBallAt,
placeObjectAt, placeObjectAt,
@ -66,13 +68,13 @@ import {
getActionKind, getActionKind,
isActionValid, isActionValid,
removeAction, removeAction,
spreadNewStateFromOriginStateChange,
} from "../editor/ActionsDomains" } from "../editor/ActionsDomains"
import ArrowAction from "../components/actions/ArrowAction" import ArrowAction from "../components/actions/ArrowAction"
import { middlePos, Pos, ratioWithinBase } from "../geo/Pos" import { middlePos, Pos, ratioWithinBase } from "../geo/Pos"
import { Action, ActionKind } from "../model/tactic/Action" import { Action, ActionKind } from "../model/tactic/Action"
import BallAction from "../components/actions/BallAction" import BallAction from "../components/actions/BallAction"
import { import {
changePlayerBallState,
computePhantomPositioning, computePhantomPositioning,
getOrigin, getOrigin,
removePlayer, removePlayer,
@ -84,6 +86,7 @@ import {
addStepNode, addStepNode,
getAvailableId, getAvailableId,
getParent, getParent,
getStepNode,
removeStepNode, removeStepNode,
} from "../editor/StepsDomain" } from "../editor/StepsDomain"
@ -96,13 +99,19 @@ 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"
// The step identifier the editor will always open on // The step identifier the editor will always open on
const DEFAULT_STEP_ID = 1 const ROOT_STEP_ID = 1
type ComputedRelativePositions = Map<ComponentId, Pos>
type ComputedStepContent = {
content: StepContent
relativePositions: ComputedRelativePositions
}
interface TacticDto { interface TacticDto {
id: number id: number
name: string name: string
courtType: CourtType courtType: CourtType
root: StepInfoNode
} }
export interface EditorPageProps { export interface EditorPageProps {
@ -122,13 +131,19 @@ function GuestModeEditor() {
GUEST_MODE_STEP_CONTENT_STORAGE_KEY + "0", GUEST_MODE_STEP_CONTENT_STORAGE_KEY + "0",
) )
const stepInitialContent = { const stepInitialContent: ComputedStepContent = {
...(storageContent == null content: {
? { components: [] } ...(storageContent == null
: JSON.parse(storageContent)), ? { components: [] }
stepId: 0, : JSON.parse(storageContent)),
},
relativePositions: new Map(),
} }
const rootStepNode: StepInfoNode = JSON.parse(
localStorage.getItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY)!,
)
// initialize local storage if we launch in guest mode // initialize local storage if we launch in guest mode
if (storageContent == null) { if (storageContent == null) {
localStorage.setItem( localStorage.setItem(
@ -136,37 +151,72 @@ function GuestModeEditor() {
JSON.stringify({ id: 0, children: [] }), JSON.stringify({ id: 0, children: [] }),
) )
localStorage.setItem( localStorage.setItem(
GUEST_MODE_STEP_CONTENT_STORAGE_KEY + stepInitialContent.stepId, GUEST_MODE_STEP_CONTENT_STORAGE_KEY + ROOT_STEP_ID,
JSON.stringify(stepInitialContent), JSON.stringify(stepInitialContent),
) )
} }
const [stepId, setStepId] = useState(DEFAULT_STEP_ID) const [stepId, setStepId] = useState(ROOT_STEP_ID)
const [stepContent, setStepContent, saveState] = useContentState( const [stepContent, setStepContent, saveState] = useContentState(
stepInitialContent, stepInitialContent,
SaveStates.Guest, SaveStates.Guest,
useMemo( useMemo(
() => () =>
debounceAsync(async (content: StepContent) => { debounceAsync(
localStorage.setItem( async ({
GUEST_MODE_STEP_CONTENT_STORAGE_KEY + stepId, content,
JSON.stringify(content), relativePositions,
) }: ComputedStepContent) => {
return SaveStates.Guest localStorage.setItem(
}, 250), GUEST_MODE_STEP_CONTENT_STORAGE_KEY + stepId,
[stepId], JSON.stringify(content),
)
const terminalState = computeTerminalState(
content,
relativePositions,
)
const currentStepNode = getStepNode(
rootStepNode,
stepId,
)!
for (const child of currentStepNode.children) {
const childCurrentContent = getStepContent(child.id)
const childUpdatedContent =
drainTerminalStateOnChildContent(
terminalState,
childCurrentContent,
)
if (childUpdatedContent) {
localStorage.setItem(
GUEST_MODE_STEP_CONTENT_STORAGE_KEY +
stepId,
JSON.stringify(childUpdatedContent),
)
}
}
return SaveStates.Guest
},
250,
),
[rootStepNode, stepId],
), ),
) )
function getStepContent(step: number): StepContent {
return JSON.parse(
localStorage.getItem(GUEST_MODE_STEP_CONTENT_STORAGE_KEY + step)!,
)
}
return ( return (
<EditorPage <EditorPage
tactic={{ tactic={{
id: -1, id: -1,
rootStepNode: JSON.parse( rootStepNode,
localStorage.getItem(
GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY,
)!,
),
name: name:
localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY) ?? localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY) ??
"Nouvelle Tactique", "Nouvelle Tactique",
@ -184,13 +234,10 @@ function GuestModeEditor() {
(step) => { (step) => {
setStepId(step) setStepId(step)
setStepContent( setStepContent(
{ () => ({
...JSON.parse( content: getStepContent(step),
localStorage.getItem( relativePositions: new Map(),
GUEST_MODE_STEP_CONTENT_STORAGE_KEY + step, }),
)!,
),
},
false, false,
) )
return return
@ -235,39 +282,80 @@ function GuestModeEditor() {
function UserModeEditor() { function UserModeEditor() {
const [tactic, setTactic] = useState<TacticDto | null>(null) const [tactic, setTactic] = useState<TacticDto | null>(null)
const [stepsTree, setStepsTree] = useState<StepInfoNode>({ id: ROOT_STEP_ID, children: [] })
const { tacticId: idStr } = useParams() const { tacticId: idStr } = useParams()
const id = parseInt(idStr!) const tacticId = parseInt(idStr!)
const navigation = useNavigate() const navigation = useNavigate()
const [stepId, setStepId] = useState(1) const [stepId, setStepId] = useState(1)
const saveContent = useCallback(
async ({ content, relativePositions }: ComputedStepContent) => {
const response = await fetchAPI(
`tactics/${tacticId}/steps/${stepId}`,
{ content },
"PUT",
)
const terminalStateContent = computeTerminalState(
content,
relativePositions,
)
const currentNode = getStepNode(stepsTree!, stepId)!
const tasks = currentNode.children.map(async (child) => {
const response = await fetchAPIGet(
`tactics/${tacticId}/steps/${child.id}`,
)
if (!response.ok)
throw new Error("Error when retrieving children content")
const childContent: StepContent = await response.json()
const childUpdatedContent = drainTerminalStateOnChildContent(
terminalStateContent,
childContent,
)
if (childUpdatedContent) {
const response = await fetchAPI(
`tactics/${tacticId}/steps/${child.id}`,
{ content: childUpdatedContent },
"PUT",
)
if (!response.ok) {
throw new Error(
"Error when updated new children content",
)
}
}
})
for (const task of tasks) {
await task
}
return response.ok ? SaveStates.Ok : SaveStates.Err
},
[tacticId, stepId, stepsTree],
)
const [stepContent, setStepContent, saveState] = const [stepContent, setStepContent, saveState] =
useContentState<StepContent>( useContentState<ComputedStepContent>(
{ components: [] }, {
content: { components: [] },
relativePositions: new Map(),
},
SaveStates.Ok, SaveStates.Ok,
useMemo( useMemo(() => debounceAsync(saveContent, 250), [saveContent]),
() =>
debounceAsync(async (content: StepContent) => {
const response = await fetchAPI(
`tactics/${id}/steps/${stepId}`,
{
content: {
components: content.components,
},
},
"PUT",
)
return response.ok ? SaveStates.Ok : SaveStates.Err
}, 250),
[id, stepId],
),
) )
useEffect(() => { useEffect(() => {
async function initialize() { async function initialize() {
const infoResponsePromise = fetchAPIGet(`tactics/${id}`) const infoResponsePromise = fetchAPIGet(`tactics/${tacticId}`)
const treeResponsePromise = fetchAPIGet(`tactics/${id}/tree`) const treeResponsePromise = fetchAPIGet(`tactics/${tacticId}/tree`)
const contentResponsePromise = fetchAPIGet( const contentResponsePromise = fetchAPIGet(
`tactics/${id}/steps/${DEFAULT_STEP_ID}`, `tactics/${tacticId}/steps/${ROOT_STEP_ID}`,
) )
const infoResponse = await infoResponsePromise const infoResponse = await infoResponsePromise
@ -287,48 +375,59 @@ function UserModeEditor() {
const content = await contentResponse.json() const content = await contentResponse.json()
const { root } = await treeResponse.json() const { root } = await treeResponse.json()
setTactic({ id, name, courtType, root }) setTactic({ id: tacticId, name, courtType })
setStepContent(content, false) setStepsTree(root)
setStepContent({ content, relativePositions: new Map() }, false)
} }
initialize() if (tactic === null)
}, [id, idStr, navigation]) initialize()
}, [tactic, tacticId, idStr, navigation, setStepContent])
const onNameChange = useCallback( const onNameChange = useCallback(
(name: string) => (name: string) =>
fetchAPI(`tactics/${id}/name`, { name }, "PUT").then((r) => r.ok), fetchAPI(`tactics/${tacticId}/name`, { name }, "PUT").then((r) => r.ok),
[id], [tacticId],
) )
const selectStep = useCallback( const selectStep = useCallback(
async (step: number) => { async (step: number) => {
const response = await fetchAPIGet(`tactics/${id}/steps/${step}`) const response = await fetchAPIGet(`tactics/${tacticId}/steps/${step}`)
if (!response.ok) return if (!response.ok) return
setStepId(step) setStepId(step)
setStepContent({ ...(await response.json()) }, false) setStepContent(
{
content: await response.json(),
relativePositions: new Map(),
},
false,
)
}, },
[id, setStepContent], [tacticId, setStepContent],
) )
const onAddStep = useCallback( const onAddStep = useCallback(
async (parent: StepInfoNode, content: StepContent) => { async (parent: StepInfoNode, content: StepContent) => {
const response = await fetchAPI(`tactics/${id}/steps`, { const response = await fetchAPI(`tactics/${tacticId}/steps`, {
parentId: parent.id, parentId: parent.id,
content, content,
}) })
if (!response.ok) return null if (!response.ok) return null
const { stepId } = await response.json() const { stepId } = await response.json()
return { id: stepId, children: [] } const child = { id: stepId, children: [] }
setStepsTree(addStepNode(stepsTree, parent, child))
return child
}, },
[id], [tacticId, stepsTree],
) )
const onRemoveStep = useCallback( const onRemoveStep = useCallback(
(step: StepInfoNode) => async (step: StepInfoNode) => {
fetchAPI(`tactics/${id}/steps/${step.id}`, {}, "DELETE").then( const response = await fetchAPI(`tactics/${tacticId}/steps/${step.id}`, {}, "DELETE")
(r) => r.ok, setStepsTree(removeStepNode(stepsTree, step)!)
), return response.ok
[id], },
[tacticId, stepsTree],
) )
if (!tactic) return <EditorLoadingScreen /> if (!tactic) return <EditorLoadingScreen />
@ -336,9 +435,9 @@ function UserModeEditor() {
return ( return (
<EditorPage <EditorPage
tactic={{ tactic={{
id, id: tacticId,
name: tactic?.name ?? "", name: tactic?.name ?? "",
rootStepNode: tactic?.root ?? { id: stepId, children: [] }, rootStepNode: stepsTree,
courtType: tactic?.courtType, courtType: tactic?.courtType,
}} }}
currentStepId={stepId} currentStepId={stepId}
@ -359,10 +458,10 @@ function EditorLoadingScreen() {
export interface EditorViewProps { export interface EditorViewProps {
tactic: TacticInfo tactic: TacticInfo
currentStepContent: StepContent currentStepContent: ComputedStepContent
currentStepId: number currentStepId: number
saveState: SaveState saveState: SaveState
setCurrentStepContent: Dispatch<SetStateAction<StepContent>> setCurrentStepContent: Dispatch<SetStateAction<ComputedStepContent>>
selectStep: (stepId: number) => void selectStep: (stepId: number) => void
onNameChange: (name: string) => Promise<boolean> onNameChange: (name: string) => Promise<boolean>
@ -374,16 +473,16 @@ export interface EditorViewProps {
} }
function EditorPage({ function EditorPage({
tactic: { name, rootStepNode: initialStepsNode, courtType }, tactic: { name, rootStepNode: initialStepsNode, courtType },
currentStepId, currentStepId,
setCurrentStepContent: setContent, setCurrentStepContent,
currentStepContent: content, currentStepContent: { content, relativePositions },
saveState, saveState,
onNameChange, onNameChange,
selectStep, selectStep,
onRemoveStep, onRemoveStep,
onAddStep, onAddStep,
}: EditorViewProps) { }: EditorViewProps) {
const [titleStyle, setTitleStyle] = useState<CSSProperties>({}) const [titleStyle, setTitleStyle] = useState<CSSProperties>({})
const [rootStepsNode, setRootStepsNode] = useState(initialStepsNode) const [rootStepsNode, setRootStepsNode] = useState(initialStepsNode)
@ -406,6 +505,32 @@ function EditorPage({
const [isStepsTreeVisible, setStepsTreeVisible] = useState(false) const [isStepsTreeVisible, setStepsTreeVisible] = useState(false)
const courtRef = useRef<HTMLDivElement>(null) const courtRef = useRef<HTMLDivElement>(null)
const courtBounds = useCallback(
() => courtRef.current!.getBoundingClientRect(),
[courtRef],
)
const setContent = useCallback(
(newState: SetStateAction<StepContent>) => {
setCurrentStepContent((c) => {
const state =
typeof newState === "function"
? newState(c.content)
: newState
const courtBounds = courtRef.current?.getBoundingClientRect()
const relativePositions: ComputedRelativePositions = courtBounds ? computeRelativePositions(courtBounds, state) : new Map()
console.log("in set: ", relativePositions)
return {
content: state,
relativePositions,
}
})
},
[setCurrentStepContent],
)
const setComponents = (action: SetStateAction<TacticComponent[]>) => { const setComponents = (action: SetStateAction<TacticComponent[]>) => {
setContent((c) => ({ setContent((c) => ({
@ -415,11 +540,6 @@ function EditorPage({
})) }))
} }
const courtBounds = useCallback(
() => courtRef.current!.getBoundingClientRect(),
[courtRef],
)
useEffect(() => { useEffect(() => {
setObjects(isBallOnCourt(content) ? [] : [{ key: "ball" }]) setObjects(isBallOnCourt(content) ? [] : [{ key: "ball" }])
}, [setObjects, content]) }, [setObjects, content])
@ -458,11 +578,12 @@ function EditorPage({
(newBounds: DOMRect, from?: PlayerLike) => { (newBounds: DOMRect, from?: PlayerLike) => {
setContent((content) => { setContent((content) => {
if (from) { if (from) {
content = changePlayerBallState( content =
from, spreadNewStateFromOriginStateChange(
BallState.NONE, from,
content, BallState.NONE,
) content,
) ?? content
} }
content = placeBallAt(newBounds, courtBounds(), content) content = placeBallAt(newBounds, courtBounds(), content)
@ -560,6 +681,7 @@ function EditorPage({
pos: computePhantomPositioning( pos: computePhantomPositioning(
component, component,
content, content,
relativePositions,
courtBounds(), courtBounds(),
), ),
ballState: component.ballState, ballState: component.ballState,
@ -585,10 +707,12 @@ function EditorPage({
) )
}, },
[ [
content.components, content,
relativePositions,
courtBounds,
validatePlayerPosition,
doRemovePlayer, doRemovePlayer,
renderAvailablePlayerActions, renderAvailablePlayerActions,
validatePlayerPosition,
], ],
) )
@ -759,7 +883,10 @@ function EditorPage({
async (parent) => { async (parent) => {
const addedNode = await onAddStep( const addedNode = await onAddStep(
parent, parent,
getTerminalState(content, courtBounds()), computeTerminalState(
content,
relativePositions,
),
) )
if (addedNode == null) { if (addedNode == null) {
console.error( console.error(
@ -772,7 +899,7 @@ function EditorPage({
addStepNode(root, parent, addedNode), addStepNode(root, parent, addedNode),
) )
}, },
[content, courtBounds, onAddStep, selectStep], [content, onAddStep, selectStep, relativePositions],
)} )}
onRemoveNode={useCallback( onRemoveNode={useCallback(
async (removed) => { async (removed) => {
@ -805,13 +932,13 @@ interface EditorStepsTreeProps {
} }
function EditorStepsTree({ function EditorStepsTree({
isVisible, isVisible,
selectedStepId, selectedStepId,
root, root,
onAddChildren, onAddChildren,
onRemoveNode, onRemoveNode,
onStepSelected, onStepSelected,
}: EditorStepsTreeProps) { }: EditorStepsTreeProps) {
return ( return (
<div <div
id="steps-div" id="steps-div"
@ -840,12 +967,12 @@ interface PlayerRackProps {
} }
function PlayerRack({ function PlayerRack({
id, id,
objects, objects,
setObjects, setObjects,
courtRef, courtRef,
setComponents, setComponents,
}: PlayerRackProps) { }: PlayerRackProps) {
const courtBounds = useCallback( const courtBounds = useCallback(
() => courtRef.current!.getBoundingClientRect(), () => courtRef.current!.getBoundingClientRect(),
[courtRef], [courtRef],
@ -899,15 +1026,15 @@ interface CourtPlayerArrowActionProps {
} }
function CourtPlayerArrowAction({ function CourtPlayerArrowAction({
playerInfo, playerInfo,
player, player,
isInvalid, isInvalid,
content, content,
setContent, setContent,
setPreviewAction, setPreviewAction,
courtRef, courtRef,
}: CourtPlayerArrowActionProps) { }: CourtPlayerArrowActionProps) {
const courtBounds = useCallback( const courtBounds = useCallback(
() => courtRef.current!.getBoundingClientRect(), () => courtRef.current!.getBoundingClientRect(),
[courtRef], [courtRef],
@ -1048,12 +1175,8 @@ function debounceAsync<A, B>(
function useContentState<S>( function useContentState<S>(
initialContent: S, initialContent: S,
initialSaveState: SaveState, initialSaveState: SaveState,
saveStateCallback: (s: S) => Promise<SaveState>, applyStateCallback: (content: S) => Promise<SaveState>,
): [ ): [S, (newState: SetStateAction<S>, runCallback: boolean) => void, SaveState] {
S,
(newState: SetStateAction<S>, callSaveCallback: boolean) => void,
SaveState,
] {
const [content, setContent] = useState(initialContent) const [content, setContent] = useState(initialContent)
const [savingState, setSavingState] = useState(initialSaveState) const [savingState, setSavingState] = useState(initialSaveState)
@ -1067,15 +1190,38 @@ function useContentState<S>(
if (state !== content && callSaveCallback) { if (state !== content && callSaveCallback) {
setSavingState(SaveStates.Saving) setSavingState(SaveStates.Saving)
saveStateCallback(state) applyStateCallback(state)
.then(setSavingState) .then(setSavingState)
.catch(() => setSavingState(SaveStates.Err)) .catch((e) => {
setSavingState(SaveStates.Err)
console.error(e)
})
} }
return state return state
}) })
}, },
[saveStateCallback], [applyStateCallback],
) )
return [content, setContentSynced, savingState] return [content, setContentSynced, savingState]
} }
function computeRelativePositions(courtBounds: DOMRect, content: StepContent) {
const relativePositionsCache: ComputedRelativePositions = new Map()
for (const component of content.components) {
if (component.type !== "phantom") continue
computePhantomPositioning(
component,
content,
relativePositionsCache,
courtBounds,
)
}
console.log("computed bounds: ", relativePositionsCache)
return relativePositionsCache
}
Loading…
Cancel
Save