From 6c9247106240faadd1b02403d982b836513038c8 Mon Sep 17 00:00:00 2001 From: Override-6 Date: Sun, 10 Dec 2023 16:37:01 +0100 Subject: [PATCH] add head/tails, add control points --- front/components/actions/ArrowAction.tsx | 6 +- front/components/actions/RemoveAction.tsx | 7 +- front/components/arrows/BendableArrow.tsx | 305 +++++++++++++++++++--- front/components/arrows/Pos.ts | 12 +- front/components/editor/BasketCourt.tsx | 69 +++-- front/components/editor/CourtPlayer.tsx | 4 +- front/style/actions/arrow_action.css | 7 +- front/style/actions/remove_action.css | 2 +- front/style/bendable_arrows.css | 20 ++ front/style/colors.css | 13 + front/style/player.css | 5 +- front/tactic/Action.ts | 10 +- front/tactic/Player.ts | 3 +- front/views/Editor.tsx | 42 ++- front/views/editor/ActionsRender.tsx | 24 -- front/views/editor/CourtAction.tsx | 25 ++ src/App/react-display-file.php | 1 + 17 files changed, 429 insertions(+), 126 deletions(-) create mode 100644 front/style/bendable_arrows.css create mode 100644 front/style/colors.css delete mode 100644 front/views/editor/ActionsRender.tsx create mode 100644 front/views/editor/CourtAction.tsx diff --git a/front/components/actions/ArrowAction.tsx b/front/components/actions/ArrowAction.tsx index 286d0aa..54a5527 100644 --- a/front/components/actions/ArrowAction.tsx +++ b/front/components/actions/ArrowAction.tsx @@ -5,14 +5,14 @@ import {useRef} from "react" export interface ArrowActionProps { onHeadDropped: (headBounds: DOMRect) => void - onHeadPicked: (headBounds: DOMRect) => void, - onHeadMoved: (headBounds: DOMRect) => void, + onHeadPicked: (headBounds: DOMRect) => void + onHeadMoved: (headBounds: DOMRect) => void } export default function ArrowAction({ onHeadDropped, onHeadPicked, - onHeadMoved + onHeadMoved, }: ArrowActionProps) { const arrowHeadRef = useRef(null) diff --git a/front/components/actions/RemoveAction.tsx b/front/components/actions/RemoveAction.tsx index 1992453..bea72da 100644 --- a/front/components/actions/RemoveAction.tsx +++ b/front/components/actions/RemoveAction.tsx @@ -6,10 +6,5 @@ export interface RemoveActionProps { } export default function RemoveAction({ onRemove }: RemoveActionProps) { - return ( - - ) + return } diff --git a/front/components/arrows/BendableArrow.tsx b/front/components/arrows/BendableArrow.tsx index 2374251..d37805d 100644 --- a/front/components/arrows/BendableArrow.tsx +++ b/front/components/arrows/BendableArrow.tsx @@ -1,27 +1,33 @@ -import { CSSProperties, ReactElement, useCallback, useEffect, useRef } from "react" -import { add, angle, Pos, relativeTo, size } from "./Pos" +import {CSSProperties, ReactElement, useCallback, useEffect, useRef, useState,} from "react" +import {angle, middlePos, Pos, relativeTo} from "./Pos" + +import "../../style/bendable_arrows.css" +import Draggable from "react-draggable" export interface BendableArrowProps { - basePos: Pos startPos: Pos - endPos: Pos + segments: Segment[] + onSegmentsChanges: (edges: Segment[]) => void startRadius?: number endRadius?: number - style?: ArrowStyle } export interface ArrowStyle { - width?: number, - head?: () => ReactElement, - tail?: () => ReactElement, + width?: number + head?: () => ReactElement + tail?: () => ReactElement } - const ArrowStyleDefaults = { - width: 4 + width: 4, +} + +export interface Segment { + next: Pos + controlPoint?: Pos } function constraintInCircle(pos: Pos, from: Pos, radius: number): Pos { @@ -29,66 +35,279 @@ function constraintInCircle(pos: Pos, from: Pos, radius: number): Pos { return { x: pos.x - Math.sin(theta) * radius, - y: pos.y - Math.cos(theta) * radius + y: pos.y - Math.cos(theta) * radius, } } -export default function BendableArrow({ basePos, startPos, endPos, style, startRadius = 0, endRadius = 0 }: BendableArrowProps) { + +function Triangle({fill}: {fill: string}) { + return ( + + + + ) +} + +export default function BendableArrow({ + startPos, + + segments, + onSegmentsChanges, + + style, + startRadius = 0, + endRadius = 0, + }: BendableArrowProps) { + + + const containerRef = useRef(null) const svgRef = useRef(null) - const pathRef = useRef(null); + const pathRef = useRef(null) const styleWidth = style?.width ?? ArrowStyleDefaults.width + const [controlPointsDots, setControlPointsDots] = useState( + [], + ) + + useEffect(() => { + setInternalSegments(segments) + }, [segments]); + + const [internalSegments, setInternalSegments] = useState(segments) + + const [isSelected, setIsSelected] = useState(false) + + + const headRef = useRef(null) + const tailRef = useRef(null) + + useEffect(() => { + + const basePos = containerRef.current!.parentElement!.getBoundingClientRect() + + setControlPointsDots(computeControlPoints(basePos)) + + const selectionHandler = (e: MouseEvent) => { + if (!(e.target instanceof Node)) + return + + setIsSelected(containerRef.current!.contains(e.target)) + } + + document.addEventListener('mousedown', selectionHandler) + return () => document.removeEventListener('mousedown', selectionHandler) + + }, []); + + + function computeControlPoints(basePos: Pos) { + return internalSegments.map(({next, controlPoint}, i) => { + const prev = i == 0 ? startPos : internalSegments[i - 1].next + + const prevRelative = relativeTo(prev, basePos) + const nextRelative = relativeTo(next, basePos) + + + const cpPos = controlPoint ? relativeTo(controlPoint, basePos) : { + x: prevRelative.x / 2 + nextRelative.x / 2, + y: prevRelative.y / 2 + nextRelative.y / 2, + } + + return ( + { + const segment = internalSegments[i] + const segments = internalSegments.toSpliced(i, 1, {...segment, controlPoint}) + onSegmentsChanges(segments) + }} + onMoves={(controlPoint) => { + setInternalSegments(is => { + return is.toSpliced(i, 1, {...is[i], controlPoint}) + }) + }} + /> + ) + }) + } + + + const update = useCallback(() => { + + // only one segment is supported for now, which is the first. + // any other segments will be ignored + const segment = internalSegments[0] ?? null + + if (segment == null) throw new Error("segments might not be empty.") + + const endPos = segment.next + + const basePos = containerRef.current!.parentElement!.getBoundingClientRect() - const update = () => { const startRelative = relativeTo(startPos, basePos) - const endRelative = relativeTo(endPos, basePos) + const endRelative = relativeTo(endPos!, basePos) - const tailPos = constraintInCircle(startRelative, endRelative, startRadius) - const headPos = constraintInCircle(endRelative, startRelative, endRadius) + const controlPoint = segment.controlPoint ? relativeTo(segment.controlPoint, basePos) : { + x: startRelative.x / 2 + endRelative.x / 2, + y: startRelative.y / 2 + endRelative.y / 2, + } - // the width and height of the arrow svg - const svgBoxBounds = size(startPos, endPos) + const tailPos = constraintInCircle( + startRelative, + controlPoint, + startRadius!, + ) + const headPos = constraintInCircle( + endRelative, + controlPoint, + endRadius!, + ) const left = Math.min(tailPos.x, headPos.x) const top = Math.min(tailPos.y, headPos.y) - const svgStyle: CSSProperties = { - width: `${svgBoxBounds.x}px`, - height: `${svgBoxBounds.y}px`, + Object.assign(tailRef.current!.style, { + left: tailPos.x + "px", + top: tailPos.y + "px", + transformOrigin: "top center", + transform: `translateX(-50%) rotate(${-angle(tailPos, controlPoint) * (180 / Math.PI)}deg)` + } as CSSProperties) + + + Object.assign(headRef.current!.style, { + left: headPos.x + "px", + top: headPos.y + "px", + transformOrigin: "top center", + transform: `translateX(-50%) rotate(${-angle(headPos, controlPoint) * (180 / Math.PI)}deg)` + } as CSSProperties) + - left: `${left}px`, - top: `${top}px`, + const svgStyle: CSSProperties = { + left: left + "px", + top: top + "px", } + const segmentsRelatives = internalSegments.map(({next, controlPoint}) => { + return { + next: relativeTo(next, basePos), + cp: controlPoint ? relativeTo(controlPoint, basePos) : undefined + } + }) - const d = `M${tailPos.x - left} ${tailPos.y - top} L${headPos.x - left} ${headPos.y - top}` - pathRef.current!.setAttribute("d", d) + const computedSegments = segmentsRelatives + .map(({next: n, cp}, idx) => { + let next = n + + if (idx == internalSegments.length - 1) { + //if it is the last element + next = constraintInCircle( + next, + controlPoint, + endRadius!, + ) + } + + if (cp == undefined) { + return `L${next.x - left} ${next.y - top}` + } + return `C${cp.x - left} ${cp.y - top}, ${cp.x - left} ${cp.y - top}, ${next.x - left} ${next.y - top}` + }) + .join(" ") + + const d = `M${tailPos.x - left} ${tailPos.y - top} ` + computedSegments + pathRef.current!.setAttribute("d", d) Object.assign(svgRef.current!.style, svgStyle) - } - useEffect(() => { - //update on resize - window.addEventListener('resize', update) + if (isSelected) { + setControlPointsDots(computeControlPoints(basePos)) + } + }, [startPos, internalSegments]) - return () => window.removeEventListener('resize', update) - }, [svgRef, basePos, startPos, endPos]) - //update on position changes - useEffect(update, [svgRef, basePos, startPos, endPos]) + + useEffect(update, [update]) return ( - - - +
+ + + + +
+ {style?.head?.call(style) ?? } +
+ +
+ {style?.tail?.call(style) ?? } +
+ + {isSelected && controlPointsDots} +
+ ) +} + +interface ControlPointProps { + pos: Pos + basePos: Pos, + onMoves: (currentPos: Pos) => void + onPosValidated: (newPos: Pos) => void, + radius?: number +} + +function ControlPoint({ + pos, + onMoves, + onPosValidated, + radius = 7, + }: ControlPointProps) { + const ref = useRef(null) + + return ( + { + const pointPos = middlePos(ref.current!.getBoundingClientRect()) + onPosValidated(pointPos) + }} + onDrag={() => { + const pointPos = middlePos(ref.current!.getBoundingClientRect()) + onMoves(pointPos) + }} + position={{x: pos.x - radius, y: pos.y - radius}} + > +
+ ) } diff --git a/front/components/arrows/Pos.ts b/front/components/arrows/Pos.ts index 0d3aa9c..d829015 100644 --- a/front/components/arrows/Pos.ts +++ b/front/components/arrows/Pos.ts @@ -3,7 +3,7 @@ export interface Pos { y: number } -export const NULL_POS: Pos = { x: 0, y: 0 } +export const NULL_POS: Pos = {x: 0, y: 0} /** * Returns position of a relative to b @@ -11,7 +11,7 @@ export const NULL_POS: Pos = { x: 0, y: 0 } * @param b */ export function relativeTo(a: Pos, b: Pos): Pos { - return { x: a.x - b.x, y: a.y - b.y } + return {x: a.x - b.x, y: a.y - b.y} } /** @@ -19,7 +19,7 @@ export function relativeTo(a: Pos, b: Pos): Pos { * @param rect */ export function middlePos(rect: DOMRect): Pos { - return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 } + return {x: rect.x + rect.width / 2, y: rect.y + rect.height / 2} } /** @@ -28,14 +28,14 @@ export function middlePos(rect: DOMRect): Pos { * @param b */ export function size(a: Pos, b: Pos): Pos { - return { x: Math.abs(a.x - b.x), y: Math.abs(a.y - b.y) } + return {x: Math.abs(a.x - b.x), y: Math.abs(a.y - b.y)} } export function add(a: Pos, b: Pos): Pos { - return { x: a.x + b.x, y: a.y + b.y } + return {x: a.x + b.x, y: a.y + b.y} } export function angle(a: Pos, b: Pos): number { const r = relativeTo(a, b) return Math.atan2(r.x, r.y) -} \ No newline at end of file +} diff --git a/front/components/editor/BasketCourt.tsx b/front/components/editor/BasketCourt.tsx index 8f49c51..822ab62 100644 --- a/front/components/editor/BasketCourt.tsx +++ b/front/components/editor/BasketCourt.tsx @@ -1,25 +1,25 @@ import "../../style/basket_court.css" +import {CourtBall} from "./CourtBall"; + -import {ReactElement, RefObject, useState} from "react" +import {ReactElement, RefObject, useCallback, useState,} from "react" import CourtPlayer from "./CourtPlayer" import {Player} from "../../tactic/Player" import {Action, MovementActionKind} from "../../tactic/Action" import RemoveAction from "../actions/RemoveAction" import ArrowAction from "../actions/ArrowAction" -import {useXarrow} from "react-xarrows" -import BendableArrow from "../arrows/BendableArrow" +import BendableArrow, {Segment} from "../arrows/BendableArrow" import {middlePos, NULL_POS, Pos} from "../arrows/Pos" -import {CourtObject} from "../../tactic/CourtObjects"; -import {CourtBall} from "./CourtBall"; import BallAction from "../actions/BallAction"; +import {CourtObject} from "../../tactic/CourtObjects"; export interface BasketCourtProps { players: Player[] actions: Action[] objects: CourtObject[] - renderAction: (courtBounds: DOMRect, a: Action, idx: number) => ReactElement + renderAction: (a: Action, key: number) => ReactElement setActions: (f: (a: Action[]) => Action[]) => void onPlayerRemove: (p: Player) => void onPlayerChange: (p: Player) => void @@ -64,35 +64,55 @@ export function BasketCourt({ playerBounds.left > arrowHead.right ) ) { - const action = { + const targetPos = document.getElementById(player.id)!.getBoundingClientRect() + const action: Action = { + fromPlayerId: originRef.id, + toPlayerId: player.id, type: MovementActionKind.SCREEN, - moveFrom: originRef.id, - moveTo: player.id, + moveFrom: middlePos(originRef.getBoundingClientRect()), + segments: [{next: middlePos(targetPos)}], } setActions((actions) => [...actions, action]) } } } - const updateArrows = useXarrow() - const [previewArrowOriginPos, setPreviewArrowOriginPos] = useState(NULL_POS) - const [previewArrowEndPos, setPreviewArrowEndPos] = useState(NULL_POS) const [isPreviewArrowEnabled, setPreviewArrowEnabled] = useState(false) + const [previewArrowEdges, setPreviewArrowEdges] = useState([]) + + const updateActionsRelatedTo = useCallback((player: Player) => { + const newPos = middlePos(document.getElementById(player.id)!.getBoundingClientRect()) + setActions(actions => actions.map(a => { + if (a.fromPlayerId == player.id) { + return {...a, moveFrom: newPos} + } + + if (a.toPlayerId == player.id) { + const segments = a.segments.toSpliced(a.segments.length - 1, 1, { + ...a.segments[a.segments.length - 1], + next: newPos + }) + return {...a, segments} + } + + return a + })) + }, []) + return (
{"court"} - {actions.map((action, idx) => renderAction(courtRef.current!.getBoundingClientRect(), action, idx))} - + {actions.map((action, idx) => renderAction(action, idx))} {players.map((player) => ( updateActionsRelatedTo(player)} onChange={onPlayerChange} onRemove={() => onPlayerRemove(player)} parentRef={courtRef} @@ -104,18 +124,22 @@ export function BasketCourt({ - setPreviewArrowEndPos(middlePos(headPos)) + setPreviewArrowEdges([ + {next: middlePos(headPos)}, + ]) } - onHeadPicked={(headRef) => { + onHeadPicked={(headPos) => { setPreviewArrowOriginPos( middlePos(pieceRef.getBoundingClientRect()), ) - setPreviewArrowEndPos(middlePos(headRef)) + setPreviewArrowEdges([ + {next: middlePos(headPos)}, + ]) setPreviewArrowEnabled(true) }} onHeadDropped={(headRect) => { - setPreviewArrowEnabled(false) bindArrowToPlayer(pieceRef, headRect) + setPreviewArrowEnabled(false) }} />, player.hasBall && onBallMoved(ref.getBoundingClientRect())}/> @@ -139,9 +163,12 @@ export function BasketCourt({ {isPreviewArrowEnabled && ( {}} + endRadius={17} + startRadius={26} /> )}
diff --git a/front/components/editor/CourtPlayer.tsx b/front/components/editor/CourtPlayer.tsx index 9df09b7..785516e 100644 --- a/front/components/editor/CourtPlayer.tsx +++ b/front/components/editor/CourtPlayer.tsx @@ -8,7 +8,7 @@ import {calculateRatio} from "../../Utils" export interface PlayerProps { player: Player - onDrag: () => void, + onDrag: () => void onChange: (p: Player) => void onRemove: () => void parentRef: RefObject @@ -30,7 +30,7 @@ export default function CourtPlayer({ const y = player.bottomRatio const hasBall = player.hasBall - const pieceRef = useRef(null); + const pieceRef = useRef(null) return ( debounceAsync((content) => + onContentChange(content).then((success) => + success ? SaveStates.Ok : SaveStates.Err, + ) + , 250), [onContentChange]) ) const [allies, setAllies] = useState( @@ -300,6 +303,7 @@ function EditorView({ ...content, players: toSplicedPlayers(content.players, player, false), objects: [...content.objects], + actions: content.actions.filter(a => a.toPlayerId !== player.id && a.fromPlayerId !== player.id), })) let setter switch (player.team) { @@ -423,7 +427,18 @@ function EditorView({ actions: actions(content.actions), })) } - renderAction={(basePos, action, idx) => } + renderAction={(action, i) => ( + + setContent((content) => ({ + ...content, + actions: content.actions.toSpliced(i, 1, a), + })) + } + /> + )} onPlayerChange={(player) => { const playerBounds = document .getElementById(player.id)! @@ -439,12 +454,9 @@ function EditorView({ player, true, ), - actions: content.actions, })) }} - onPlayerRemove={(player) => { - removePlayer(player) - }} + onPlayerRemove={removePlayer} onBallRemove={removeCourtBall} />
@@ -478,6 +490,16 @@ function getRackPlayers(team: Team, players: Player[]): RackedPlayer[] { .map((key) => ({ team, key })) } +function debounceAsync(f: (args: A) => Promise, delay = 1000): (args: A) => Promise { + let task = 0; + return (args: A) => { + clearTimeout(task) + return new Promise(resolve => { + task = setTimeout(() => f(args).then(resolve), delay) + }) + } +} + function useContentState( initialContent: S, initialSaveState: SaveState, diff --git a/front/views/editor/ActionsRender.tsx b/front/views/editor/ActionsRender.tsx deleted file mode 100644 index 3e93165..0000000 --- a/front/views/editor/ActionsRender.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Action, MovementActionKind } from "../../tactic/Action" -import Xarrow, { Xwrapper } from "react-xarrows" -import { xarrowPropsType } from "react-xarrows/lib/types" -import BendableArrow from "../../components/arrows/BendableArrow" -import { middlePos, Pos } from "../../components/arrows/Pos" - - -export function ActionRender({basePos, action}: {basePos: Pos, action: Action}) { - - const from = action.moveFrom; - const to = action.moveTo; - - const fromPos = document.getElementById(from)!.getBoundingClientRect() - const toPos = document.getElementById(to)!.getBoundingClientRect() - - return ( - - ) -} diff --git a/front/views/editor/CourtAction.tsx b/front/views/editor/CourtAction.tsx new file mode 100644 index 0000000..90fadab --- /dev/null +++ b/front/views/editor/CourtAction.tsx @@ -0,0 +1,25 @@ +import {Action} from "../../tactic/Action" +import BendableArrow from "../../components/arrows/BendableArrow" + +export interface CourtActionProps { + action: Action + onActionChanges: (a: Action) => void +} + +export function CourtAction({ + action, + onActionChanges, + }: CourtActionProps) { + + return ( + { + onActionChanges({...action, segments: edges}) + }} + endRadius={26} + startRadius={26} + /> + ) +} diff --git a/src/App/react-display-file.php b/src/App/react-display-file.php index 46b039f..ca672d0 100755 --- a/src/App/react-display-file.php +++ b/src/App/react-display-file.php @@ -30,6 +30,7 @@ height: 100%; width: 100%; margin: 0; + overflow: hidden; }