diff --git a/.gitignore b/.gitignore index 61df6e7..a02dfdf 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ vendor composer.lock *.phar /dist +.guard # sqlite database files *.sqlite diff --git a/front/Utils.ts b/front/Utils.ts deleted file mode 100644 index 523d813..0000000 --- a/front/Utils.ts +++ /dev/null @@ -1,12 +0,0 @@ -export function calculateRatio( - it: { x: number; y: number }, - parent: DOMRect, -): { x: number; y: number } { - const relativeXPixels = it.x - parent.x - const relativeYPixels = it.y - parent.y - - const xRatio = relativeXPixels / parent.width - const yRatio = relativeYPixels / parent.height - - return { x: xRatio, y: yRatio } -} diff --git a/front/assets/court/full_court.svg b/front/assets/court/full_court.svg index cb59a6b..5bfc0de 100644 --- a/front/assets/court/full_court.svg +++ b/front/assets/court/full_court.svg @@ -1,5 +1,4 @@ - diff --git a/front/assets/icon/arrow.svg b/front/assets/icon/arrow.svg new file mode 100644 index 0000000..87d213c --- /dev/null +++ b/front/assets/icon/arrow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/front/assets/icon/remove.svg b/front/assets/icon/remove.svg index 6886097..29aec4e 100644 --- a/front/assets/icon/remove.svg +++ b/front/assets/icon/remove.svg @@ -1,4 +1,4 @@ - + diff --git a/front/components/TitleInput.tsx b/front/components/TitleInput.tsx index 8da1c65..477e3d0 100644 --- a/front/components/TitleInput.tsx +++ b/front/components/TitleInput.tsx @@ -24,7 +24,7 @@ export default function TitleInput({ value={value} onChange={(event) => setValue(event.target.value)} onBlur={(_) => on_validated(value)} - onKeyDown={(event) => { + onKeyUp={(event) => { if (event.key == "Enter") ref.current?.blur() }} /> diff --git a/front/components/actions/ArrowAction.tsx b/front/components/actions/ArrowAction.tsx new file mode 100644 index 0000000..00a661c --- /dev/null +++ b/front/components/actions/ArrowAction.tsx @@ -0,0 +1,61 @@ +import "../../style/actions/arrow_action.css" +import Draggable from "react-draggable" +import arrowPng from "../../assets/icon/arrow.svg" +import { useRef } from "react" + +export interface ArrowActionProps { + onHeadDropped: (headBounds: DOMRect) => void + onHeadPicked: (headBounds: DOMRect) => void + onHeadMoved: (headBounds: DOMRect) => void +} + +export default function ArrowAction({ + onHeadDropped, + onHeadPicked, + onHeadMoved, +}: ArrowActionProps) { + const arrowHeadRef = useRef(null) + + return ( +
+ add arrow + + { + const headBounds = + arrowHeadRef.current!.getBoundingClientRect() + onHeadPicked(headBounds) + }} + onStop={() => { + const headBounds = + arrowHeadRef.current!.getBoundingClientRect() + onHeadDropped(headBounds) + }} + onDrag={() => { + const headBounds = + arrowHeadRef.current!.getBoundingClientRect() + onHeadMoved(headBounds) + }} + position={{ x: 0, y: 0 }}> +
+ +
+ ) +} + +export function ScreenHead() { + return ( +
+ ) +} + +export function MoveToHead() { + return ( + + + + ) +} diff --git a/front/components/actions/BallAction.tsx b/front/components/actions/BallAction.tsx new file mode 100644 index 0000000..a26785c --- /dev/null +++ b/front/components/actions/BallAction.tsx @@ -0,0 +1,18 @@ +import { BallPiece } from "../editor/BallPiece" +import Draggable from "react-draggable" +import { useRef } from "react" + +export interface BallActionProps { + onDrop: (el: HTMLElement) => void +} + +export default function BallAction({ onDrop }: BallActionProps) { + const ref = useRef(null) + return ( + onDrop(ref.current!)} nodeRef={ref}> +
+ +
+
+ ) +} diff --git a/front/components/arrows/BendableArrow.tsx b/front/components/arrows/BendableArrow.tsx new file mode 100644 index 0000000..6ecdb44 --- /dev/null +++ b/front/components/arrows/BendableArrow.tsx @@ -0,0 +1,756 @@ +import { + CSSProperties, + ReactElement, + RefObject, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + MouseEvent as ReactMouseEvent, +} from "react" +import { + add, + angle, + middle, + distance, + middlePos, + minus, + mul, + Pos, + posWithinBase, + ratioWithinBase, + relativeTo, + norm, +} from "./Pos" + +import "../../style/bendable_arrows.css" +import Draggable from "react-draggable" + +export interface BendableArrowProps { + area: RefObject + startPos: Pos + segments: Segment[] + onSegmentsChanges: (edges: Segment[]) => void + forceStraight: boolean + wavy: boolean + + startRadius?: number + endRadius?: number + + onDeleteRequested?: () => void + + style?: ArrowStyle +} + +export interface ArrowStyle { + width?: number + dashArray?: string + head?: () => ReactElement + tail?: () => ReactElement +} + +const ArrowStyleDefaults: ArrowStyle = { + width: 3, +} + +export interface Segment { + next: Pos + controlPoint?: Pos +} + +/** + * Given a circle shaped by a central position, and a radius, return + * a position that is constrained on its perimeter, pointing to the direction + * between the circle's center and the reference position. + * @param center circle's center. + * @param reference a reference point used to create the angle where the returned position + * will point to on the circle's perimeter + * @param radius circle's radius. + */ +function constraintInCircle(center: Pos, reference: Pos, radius: number): Pos { + const theta = angle(center, reference) + + return { + x: center.x - Math.sin(theta) * radius, + y: center.y - Math.cos(theta) * radius, + } +} + +/** + * An arrow that follows a bézier curve built from given segments that can be edited, added or removed by the user + * The arrow only works with relative positions within a given area. + * All position handled by the arrow must be positions where x and y are a percentage within the area's surface + * (0.5, 0.5) is a position at the middle of the area + * (1, 0.75) means that the position is at 100percent to the right of given area, and 75 percent to the bottom + * @param area + * @param startPos + * @param segments + * @param onSegmentsChanges + * @param wavy + * @param forceStraight + * @param style + * @param startRadius + * @param endRadius + * @param onDeleteRequested + * @constructor + */ +export default function BendableArrow({ + area, + startPos, + + segments, + onSegmentsChanges, + + forceStraight, + wavy, + + style, + startRadius = 0, + endRadius = 0, + onDeleteRequested, +}: BendableArrowProps) { + const containerRef = useRef(null) + const svgRef = useRef(null) + const pathRef = useRef(null) + + const styleWidth = style?.width ?? ArrowStyleDefaults.width + + const computeInternalSegments = useCallback((segments: Segment[]) => { + return segments.map((segment, idx) => { + if (idx == 0) { + return { + start: startPos, + controlPoint: segment.controlPoint ?? null, + end: segment.next, + } + } + const start = segments[idx - 1].next + return { + start, + controlPoint: segment.controlPoint ?? null, + end: segment.next, + } + }) + }, [segments, startPos]) + + // Cache the segments so that when the user is changing the segments (it moves an ArrowPoint), + // it does not unwind to this arrow's component parent until validated. + // The changes are validated (meaning that onSegmentsChanges is called) when the + // user releases an ArrowPoint. + const [internalSegments, setInternalSegments] = useState( + () => computeInternalSegments(segments), + ) + // If the (original) segments changes, overwrite the current ones. + useLayoutEffect(() => { + setInternalSegments(computeInternalSegments(segments)) + }, [startPos, segments, computeInternalSegments]) + + const [isSelected, setIsSelected] = useState(false) + + const headRef = useRef(null) + const tailRef = useRef(null) + + + + /** + * Computes and return the segments edition points + * @param parentBase + */ + function computePoints(parentBase: DOMRect) { + return segments.flatMap(({ next, controlPoint }, i) => { + const prev = i == 0 ? startPos : segments[i - 1].next + + const prevRelative = posWithinBase(prev, parentBase) + const nextRelative = posWithinBase(next, parentBase) + + const cpPos = + controlPoint || + ratioWithinBase( + add(middle(prevRelative, nextRelative), parentBase), + parentBase, + ) + + const setControlPointPos = (newPos: Pos | null) => { + const segment = segments[i] + const newSegments = segments.toSpliced(i, 1, { + ...segment, + controlPoint: newPos ?? undefined, + }) + onSegmentsChanges(newSegments) + } + + return [ + // curve control point + setControlPointPos(null)} + onMoves={(controlPoint) => { + setInternalSegments((is) => { + return is.toSpliced(i, 1, { + ...is[i], + controlPoint, + }) + }) + }} + />, + //next pos point (only if this is not the last segment) + i != segments.length - 1 && ( + { + const currentSegment = segments[i] + const newSegments = segments.toSpliced(i, 1, { + ...currentSegment, + next, + }) + onSegmentsChanges(newSegments) + }} + onRemove={() => { + onSegmentsChanges( + segments.toSpliced(Math.max(i - 1, 0), 1), + ) + }} + onMoves={(end) => { + setInternalSegments((is) => { + return is.toSpliced( + i, + 2, + { + ...is[i], + end, + }, + { + ...is[i + 1], + start: end, + }, + ) + }) + }} + /> + ), + ] + }) + } + + /** + * Updates the states based on given parameters, which causes the arrow to re-render. + */ + const update = useCallback(() => { + const parentBase = area.current!.getBoundingClientRect() + + const segment = internalSegments[0] ?? null + if (segment == null) throw new Error("segments might not be empty.") + + const lastSegment = internalSegments[internalSegments.length - 1] + + const startRelative = posWithinBase(startPos, parentBase) + const endRelative = posWithinBase(lastSegment.end, parentBase) + + const startNext = + segment.controlPoint && !forceStraight + ? posWithinBase(segment.controlPoint, parentBase) + : posWithinBase(segment.end, parentBase) + + const endPrevious = forceStraight + ? startRelative + : lastSegment.controlPoint + ? posWithinBase(lastSegment.controlPoint, parentBase) + : posWithinBase(lastSegment.start, parentBase) + + const tailPos = constraintInCircle( + startRelative, + startNext, + startRadius!, + ) + const headPos = constraintInCircle(endRelative, endPrevious, endRadius!) + + const left = Math.min(tailPos.x, headPos.x) + const top = Math.min(tailPos.y, headPos.y) + + Object.assign(tailRef.current!.style, { + left: tailPos.x + "px", + top: tailPos.y + "px", + transformOrigin: "top center", + transform: `translateX(-50%) rotate(${ + -angle(tailPos, startNext) * (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, endPrevious) * (180 / Math.PI) + }deg)`, + } as CSSProperties) + + const svgStyle: CSSProperties = { + left: left + "px", + top: top + "px", + } + + const segmentsRelatives = ( + forceStraight + ? [ + { + start: startPos, + controlPoint: undefined, + end: lastSegment.end, + }, + ] + : internalSegments + ).map(({ start, controlPoint, end }, idx) => { + const svgPosRelativeToBase = { x: left, y: top } + + const nextRelative = relativeTo( + posWithinBase(end, parentBase), + svgPosRelativeToBase, + ) + const startRelative = relativeTo( + posWithinBase(start, parentBase), + svgPosRelativeToBase, + ) + const controlPointRelative = + controlPoint && !forceStraight + ? relativeTo( + posWithinBase(controlPoint, parentBase), + svgPosRelativeToBase, + ) + : middle(startRelative, nextRelative) + + return { + start: startRelative, + end: nextRelative, + cp: controlPointRelative, + } + }) + + const computedSegments = segmentsRelatives + .map(({ start, cp, end: e }, idx) => { + let end = e + if (idx == segmentsRelatives.length - 1) { + //if it is the last element + end = constraintInCircle(end, cp, endRadius!) + } + + const previousSegment = + idx != 0 ? segmentsRelatives[idx - 1] : undefined + + const previousSegmentCpAndCurrentPosVector = minus( + start, + previousSegment?.cp ?? middle(start, end), + ) + + const smoothCp = previousSegment + ? add(start, previousSegmentCpAndCurrentPosVector) + : cp + + if (wavy) { + return wavyBezier(start, smoothCp, cp, end, 10, 10) + } + + if (forceStraight) { + return `L${end.x} ${end.y}` + } + + return `C${smoothCp.x} ${smoothCp.y}, ${cp.x} ${cp.y}, ${end.x} ${end.y}` + }) + .join(" ") + + const d = `M${tailPos.x - left} ${tailPos.y - top} ` + computedSegments + pathRef.current!.setAttribute("d", d) + Object.assign(svgRef.current!.style, svgStyle) + }, [ + startPos, + internalSegments, + forceStraight, + startRadius, + endRadius, + style, + ]) + + // Will update the arrow when the props change + useEffect(update, [update]) + + // Adds a selection handler + // Also force an update when the window is resized + useEffect(() => { + const selectionHandler = (e: MouseEvent) => { + if (!(e.target instanceof Node)) return + + // The arrow is selected if the mouse clicks on an element that belongs to the current arrow + const isSelected = containerRef.current!.contains(e.target) + setIsSelected(isSelected) + } + + document.addEventListener("mousedown", selectionHandler) + window.addEventListener("resize", update) + + return () => { + document.removeEventListener("mousedown", selectionHandler) + window.removeEventListener("resize", update) + } + }, [update, containerRef]) + + const addSegment = useCallback( + (e: ReactMouseEvent) => { + if (forceStraight) return + const parentBase = area.current!.getBoundingClientRect() + + const clickAbsolutePos: Pos = { x: e.pageX, y: e.pageY } + const clickPosBaseRatio = ratioWithinBase( + clickAbsolutePos, + parentBase, + ) + + let segmentInsertionIndex = -1 + let segmentInsertionIsOnRightOfCP = false + for (let i = 0; i < segments.length; i++) { + const segment = segments[i] + const beforeSegment = i != 0 ? segments[i - 1] : undefined + const beforeSegmentPos = i > 1 ? segments[i - 2].next : startPos + + const currentPos = beforeSegment ? beforeSegment.next : startPos + const nextPos = segment.next + const segmentCp = segment.controlPoint + ? segment.controlPoint + : middle(currentPos, nextPos) + + const smoothCp = beforeSegment + ? add( + currentPos, + minus( + currentPos, + beforeSegment.controlPoint ?? + middle(beforeSegmentPos, currentPos), + ), + ) + : segmentCp + + const result = searchOnSegment( + currentPos, + smoothCp, + segmentCp, + nextPos, + clickPosBaseRatio, + 0.05, + ) + if (result == PointSegmentSearchResult.NOT_FOUND) continue + + segmentInsertionIndex = i + segmentInsertionIsOnRightOfCP = + result == PointSegmentSearchResult.RIGHT_TO_CONTROL_POINT + break + } + + if (segmentInsertionIndex == -1) return + + const splicedSegment: Segment = segments[segmentInsertionIndex] + + onSegmentsChanges( + segments.toSpliced( + segmentInsertionIndex, + 1, + { + next: clickPosBaseRatio, + controlPoint: segmentInsertionIsOnRightOfCP + ? splicedSegment.controlPoint + : undefined, + }, + { + next: splicedSegment.next, + controlPoint: segmentInsertionIsOnRightOfCP + ? undefined + : splicedSegment.controlPoint, + }, + ), + ) + }, + [area, forceStraight, onSegmentsChanges, segments, startPos], + ) + + return ( +
+ + { + if (onDeleteRequested && e.key == "Delete") + onDeleteRequested() + }} + /> + + +
+ {style?.head?.call(style)} +
+ +
+ {style?.tail?.call(style)} +
+ + {!forceStraight && + isSelected && + computePoints(area.current!.getBoundingClientRect())} +
+ ) +} + +interface ControlPointProps { + className: string + posRatio: Pos + parentBase: DOMRect + onMoves: (currentPos: Pos) => void + onPosValidated: (newPos: Pos) => void + onRemove: () => void + radius?: number +} + +enum PointSegmentSearchResult { + LEFT_TO_CONTROL_POINT, + RIGHT_TO_CONTROL_POINT, + NOT_FOUND, +} + +interface FullSegment { + start: Pos + controlPoint: Pos | null + end: Pos +} + +/** + * returns a path delimiter that follows a given cubic béziers curve, but with additional waves on it, of the given + * density and amplitude. + * @param start + * @param cp1 + * @param cp2 + * @param end + * @param wavesPer100px + * @param amplitude + */ +function wavyBezier( + start: Pos, + cp1: Pos, + cp2: Pos, + end: Pos, + wavesPer100px: number, + amplitude: number, +): string { + function getVerticalAmplification(t: number): Pos { + const velocity = cubicBeziersDerivative(start, cp1, cp2, end, t) + const velocityLength = norm(velocity) + //rotate the velocity by 90 deg + const projection = { x: velocity.y, y: -velocity.x } + + return { + x: (projection.x / velocityLength) * amplitude, + y: (projection.y / velocityLength) * amplitude, + } + } + + let result: string = "" + + const dist = distance(start, cp1) + distance(cp1, cp2) + distance(cp2, end) + + // we need two phases in order to complete a wave + const waveLength = (dist / 100) * wavesPer100px * 2 + const step = 1 / waveLength + + // 0 : middle to up + // 1 : up to middle + // 2 : middle to down + // 3 : down to middle + let phase = 0 + + for (let t = step; t <= 1; ) { + const pos = cubicBeziers(start, cp1, cp2, end, t) + const amplification = getVerticalAmplification(t) + + let nextPos + if (phase == 1 || phase == 3) { + nextPos = pos + } else { + if (phase == 0) { + nextPos = add(pos, amplification) + } else { + nextPos = minus(pos, amplification) + } + } + + const controlPointBase: Pos = cubicBeziers( + start, + cp1, + cp2, + end, + t - step / 2, + ) + + const controlPoint: Pos = + phase == 0 || phase == 1 + ? add(controlPointBase, amplification) + : minus(controlPointBase, amplification) + + result += `Q${controlPoint.x} ${controlPoint.y} ${nextPos.x} ${nextPos.y}` + phase = (phase + 1) % 4 + t += step + if (t < 1 && t > 1 - step) t = 1 + } + + return result +} + +function cubicBeziersDerivative( + start: Pos, + cp1: Pos, + cp2: Pos, + end: Pos, + t: number, +): Pos { + return add( + add( + mul(minus(cp1, start), 3 * (1 - t) ** 2), + mul(minus(cp2, cp1), 6 * (1 - t) * t), + ), + mul(minus(end, cp2), 3 * t ** 2), + ) +} + +function cubicBeziers( + start: Pos, + cp1: Pos, + cp2: Pos, + end: Pos, + t: number, +): Pos { + return add( + add( + add(mul(start, (1 - t) ** 3), mul(cp1, 3 * t * (1 - t) ** 2)), + mul(cp2, 3 * t ** 2 * (1 - t)), + ), + mul(end, t ** 3), + ) +} + +/** + * Given a quadratic bézier curve (start position, end position and a middle control point position) + * search if the given `point` lies on the curve, within a minimum acceptance distance. + * @param start + * @param cp1 + * @param cp2 + * @param end + * @param point + * @param minDistance + */ +function searchOnSegment( + start: Pos, + cp1: Pos, + cp2: Pos, + end: Pos, + point: Pos, + minDistance: number, +): PointSegmentSearchResult { + const dist = distance(start, cp1) + distance(cp1, cp2) + distance(cp2, end) + const step = 1 / (dist / minDistance) + + function getDistanceAt(t: number): number { + return distance(cubicBeziers(start, cp1, cp2, end, t), point) + } + + for (let t = 0; t < 1; t += step) { + const distance = getDistanceAt(t) + + if (distance <= minDistance) + return t >= 0.5 + ? PointSegmentSearchResult.RIGHT_TO_CONTROL_POINT + : PointSegmentSearchResult.LEFT_TO_CONTROL_POINT + } + + return PointSegmentSearchResult.NOT_FOUND +} + +/** + * An arrow point, that can be moved. + * @param className + * @param posRatio + * @param parentBase + * @param onMoves + * @param onPosValidated + * @param onRemove + * @param radius + * @constructor + */ +function ArrowPoint({ + className, + posRatio, + parentBase, + onMoves, + onPosValidated, + onRemove, + radius = 7, +}: ControlPointProps) { + const ref = useRef(null) + + const pos = posWithinBase(posRatio, parentBase) + + return ( + { + const pointPos = middlePos(ref.current!.getBoundingClientRect()) + onPosValidated(ratioWithinBase(pointPos, parentBase)) + }} + onDrag={() => { + const pointPos = middlePos(ref.current!.getBoundingClientRect()) + onMoves(ratioWithinBase(pointPos, parentBase)) + }} + position={{ x: pos.x - radius, y: pos.y - radius }}> +
{ + if (e.key == "Delete") { + onRemove() + } + }} + tabIndex={0} + /> + + ) +} diff --git a/front/components/arrows/Box.ts b/front/components/arrows/Box.ts new file mode 100644 index 0000000..36a674f --- /dev/null +++ b/front/components/arrows/Box.ts @@ -0,0 +1,38 @@ +import { Pos } from "./Pos" + +export interface Box { + x: number + y: number + width: number + height: number +} + +export function boundsOf(...positions: Pos[]): Box { + const allPosX = positions.map((p) => p.x) + const allPosY = positions.map((p) => p.y) + + const x = Math.min(...allPosX) + const y = Math.min(...allPosY) + const width = Math.max(...allPosX) - x + const height = Math.max(...allPosY) - y + + return { x, y, width, height } +} + +export function surrounds(pos: Pos, width: number, height: number): Box { + return { + x: pos.x + width / 2, + y: pos.y + height / 2, + width, + height, + } +} + +export function contains(box: Box, pos: Pos): boolean { + return ( + pos.x >= box.x && + pos.x <= box.x + box.width && + pos.y >= box.y && + pos.y <= box.y + box.height + ) +} diff --git a/front/components/arrows/Pos.ts b/front/components/arrows/Pos.ts new file mode 100644 index 0000000..be7a704 --- /dev/null +++ b/front/components/arrows/Pos.ts @@ -0,0 +1,81 @@ +export interface Pos { + x: number + y: number +} + +export const NULL_POS: Pos = { x: 0, y: 0 } + +/** + * Returns position of a relative to b + * @param a + * @param b + */ +export function relativeTo(a: Pos, b: Pos): Pos { + return { x: a.x - b.x, y: a.y - b.y } +} + +/** + * Returns the middle position of the given rectangle + * @param rect + */ +export function middlePos(rect: DOMRect): Pos { + return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 } +} + +export function add(a: Pos, b: Pos): Pos { + return { x: a.x + b.x, y: a.y + b.y } +} + +export function minus(a: Pos, b: Pos): Pos { + return { x: a.x - b.x, y: a.y - b.y } +} + +export function mul(a: Pos, t: number): Pos { + return { x: a.x * t, y: a.y * t } +} + +export function distance(a: Pos, b: Pos): number { + return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2) +} + +export function norm(vector: Pos): number { + return distance(NULL_POS, vector) +} + +/** + * Returns the angle in radian between the two points + * @param a + * @param b + */ +export function angle(a: Pos, b: Pos): number { + const r = relativeTo(a, b) + return Math.atan2(r.x, r.y) +} + +export function ratioWithinBase(pos: Pos, base: DOMRect): Pos { + return { + x: (pos.x - base.x) / base.width, + y: (pos.y - base.y) / base.height, + } +} + +export function posWithinBase(ratio: Pos, base: DOMRect): Pos { + return { + x: ratio.x * base.width, + y: ratio.y * base.height, + } +} + +export function middle(a: Pos, b: Pos): Pos { + return { + x: a.x / 2 + b.x / 2, + y: a.y / 2 + b.y / 2, + } +} + +export function rotate(vec: Pos, rad: number): Pos { + return { + x: Math.cos(rad) * vec.x - Math.sin(rad) * vec.y, + y: Math.sin(rad) * vec.x + Math.cos(rad) * vec.y, + } +} diff --git a/front/components/editor/BallPiece.tsx b/front/components/editor/BallPiece.tsx index b09f811..2741249 100644 --- a/front/components/editor/BallPiece.tsx +++ b/front/components/editor/BallPiece.tsx @@ -1,7 +1,6 @@ import "../../style/ball.css" import BallSvg from "../../assets/icon/ball.svg?react" -import { Ball } from "../../tactic/CourtObjects" export function BallPiece() { return diff --git a/front/components/editor/BasketCourt.tsx b/front/components/editor/BasketCourt.tsx index 4545f9a..e57d669 100644 --- a/front/components/editor/BasketCourt.tsx +++ b/front/components/editor/BasketCourt.tsx @@ -1,54 +1,247 @@ -import "../../style/basket_court.css" -import { RefObject } from "react" -import CourtPlayer from "./CourtPlayer" +import { CourtBall } from "./CourtBall" +import { + ReactElement, + RefObject, + useCallback, + useLayoutEffect, + useState, +} from "react" +import CourtPlayer from "./CourtPlayer" import { Player } from "../../tactic/Player" +import { Action, ActionKind } from "../../tactic/Action" +import ArrowAction from "../actions/ArrowAction" +import { middlePos, ratioWithinBase } from "../arrows/Pos" +import BallAction from "../actions/BallAction" import { CourtObject } from "../../tactic/CourtObjects" -import { CourtBall } from "./CourtBall" +import { contains } from "../arrows/Box" +import { CourtAction } from "../../views/editor/CourtAction" export interface BasketCourtProps { players: Player[] + actions: Action[] objects: CourtObject[] + renderAction: (a: Action, key: number) => ReactElement + setActions: (f: (a: Action[]) => Action[]) => void + onPlayerRemove: (p: Player) => void onPlayerChange: (p: Player) => void onBallRemove: () => void - onBallMoved: (ball: DOMRect) => void - courtImage: string + courtImage: ReactElement courtRef: RefObject } export function BasketCourt({ players, + actions, objects, + renderAction, + setActions, onPlayerRemove, - onBallRemove, - onBallMoved, onPlayerChange, + + onBallMoved, + onBallRemove, + courtImage, courtRef, }: BasketCourtProps) { + function placeArrow(origin: Player, arrowHead: DOMRect) { + const originRef = document.getElementById(origin.id)! + const courtBounds = courtRef.current!.getBoundingClientRect() + const start = ratioWithinBase( + middlePos(originRef.getBoundingClientRect()), + courtBounds, + ) + + for (const player of players) { + if (player.id == origin.id) { + continue + } + + const playerBounds = document + .getElementById(player.id)! + .getBoundingClientRect() + + if ( + !( + playerBounds.top > arrowHead.bottom || + playerBounds.right < arrowHead.left || + playerBounds.bottom < arrowHead.top || + playerBounds.left > arrowHead.right + ) + ) { + const targetPos = document + .getElementById(player.id)! + .getBoundingClientRect() + + const end = ratioWithinBase(middlePos(targetPos), courtBounds) + + const action: Action = { + fromPlayerId: originRef.id, + toPlayerId: player.id, + type: origin.hasBall ? ActionKind.SHOOT : ActionKind.SCREEN, + moveFrom: start, + segments: [{ next: end }], + } + setActions((actions) => [...actions, action]) + return + } + } + + const action: Action = { + fromPlayerId: originRef.id, + type: origin.hasBall ? ActionKind.DRIBBLE : ActionKind.MOVE, + moveFrom: ratioWithinBase( + middlePos(originRef.getBoundingClientRect()), + courtBounds, + ), + segments: [ + { next: ratioWithinBase(middlePos(arrowHead), courtBounds) }, + ], + } + setActions((actions) => [...actions, action]) + } + + const [previewAction, setPreviewAction] = useState(null) + + const updateActionsRelatedTo = useCallback((player: Player) => { + const newPos = ratioWithinBase( + middlePos( + document.getElementById(player.id)!.getBoundingClientRect(), + ), + courtRef.current!.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 + }), + ) + }, []) + + const [internActions, setInternActions] = useState([]) + + useLayoutEffect(() => setInternActions(actions), [actions]) + return (
- {"court"} - {players.map((player) => { - return ( - onPlayerRemove(player)} - onBallDrop={onBallMoved} - parentRef={courtRef} - /> - ) - })} + {courtImage} + + {players.map((player) => ( + updateActionsRelatedTo(player)} + onChange={onPlayerChange} + onRemove={() => onPlayerRemove(player)} + courtRef={courtRef} + availableActions={(pieceRef) => [ + { + const baseBounds = + courtRef.current!.getBoundingClientRect() + + const arrowHeadPos = middlePos(headPos) + + const target = players.find( + (p) => + p != player && + contains( + document + .getElementById(p.id)! + .getBoundingClientRect(), + arrowHeadPos, + ), + ) + + setPreviewAction((action) => ({ + ...action!, + segments: [ + { + next: ratioWithinBase( + arrowHeadPos, + baseBounds, + ), + }, + ], + type: player.hasBall + ? target + ? ActionKind.SHOOT + : ActionKind.DRIBBLE + : target + ? ActionKind.SCREEN + : ActionKind.MOVE, + })) + }} + onHeadPicked={(headPos) => { + ;(document.activeElement as HTMLElement).blur() + const baseBounds = + courtRef.current!.getBoundingClientRect() + + setPreviewAction({ + type: player.hasBall + ? ActionKind.DRIBBLE + : ActionKind.MOVE, + fromPlayerId: player.id, + toPlayerId: undefined, + moveFrom: ratioWithinBase( + middlePos( + pieceRef.getBoundingClientRect(), + ), + baseBounds, + ), + segments: [ + { + next: ratioWithinBase( + middlePos(headPos), + baseBounds, + ), + }, + ], + }) + }} + onHeadDropped={(headRect) => { + placeArrow(player, headRect) + setPreviewAction(null) + }} + />, + player.hasBall && ( + + onBallMoved(ref.getBoundingClientRect()) + } + /> + ), + ]} + /> + ))} + + {internActions.map((action, idx) => renderAction(action, idx))} {objects.map((object) => { if (object.type == "ball") { @@ -63,6 +256,16 @@ export function BasketCourt({ } throw new Error("unknown court object", object.type) })} + + {previewAction && ( + {}} + onActionChanges={() => {}} + /> + )}
) } diff --git a/front/components/editor/CourtPlayer.tsx b/front/components/editor/CourtPlayer.tsx index 5f152ed..3d5ffde 100644 --- a/front/components/editor/CourtPlayer.tsx +++ b/front/components/editor/CourtPlayer.tsx @@ -1,17 +1,17 @@ -import { RefObject, useRef } from "react" +import { ReactNode, RefObject, useRef } from "react" import "../../style/player.css" -import { BallPiece } from "./BallPiece" import Draggable from "react-draggable" import { PlayerPiece } from "./PlayerPiece" import { Player } from "../../tactic/Player" -import { calculateRatio } from "../../Utils" +import { NULL_POS, ratioWithinBase } from "../arrows/Pos" export interface PlayerProps { player: Player + onDrag: () => void onChange: (p: Player) => void onRemove: () => void - onBallDrop: (bounds: DOMRect) => void - parentRef: RefObject + courtRef: RefObject + availableActions: (ro: HTMLElement) => ReactNode[] } /** @@ -19,28 +19,29 @@ export interface PlayerProps { * */ export default function CourtPlayer({ player, + onDrag, onChange, onRemove, - onBallDrop, - parentRef, + courtRef, + availableActions, }: PlayerProps) { - const pieceRef = useRef(null) - const ballPiece = useRef(null) - + const hasBall = player.hasBall const x = player.rightRatio const y = player.bottomRatio - const hasBall = player.hasBall + const pieceRef = useRef(null) return ( { const pieceBounds = pieceRef.current!.getBoundingClientRect() - const parentBounds = parentRef.current!.getBoundingClientRect() + const parentBounds = courtRef.current!.getBoundingClientRect() - const { x, y } = calculateRatio(pieceBounds, parentBounds) + const { x, y } = ratioWithinBase(pieceBounds, parentBounds) onChange({ id: player.id, @@ -52,35 +53,22 @@ export default function CourtPlayer({ }) }}>
{ if (e.key == "Delete") onRemove() }}> -
- {hasBall && ( - - onBallDrop( - ballPiece.current!.getBoundingClientRect(), - ) - } - position={{ x: 0, y: 0 }}> -
- -
-
- )} +
+ {availableActions(pieceRef.current!)}
+ debounceAsync( + (content) => + onContentChange(content).then((success) => + success ? SaveStates.Ok : SaveStates.Err, + ), + 250, + ), + [onContentChange], + ), ) const [allies, setAllies] = useState( @@ -150,7 +162,7 @@ function EditorView({ const refBounds = ref.getBoundingClientRect() const courtBounds = courtDivContentRef.current!.getBoundingClientRect() - const { x, y } = calculateRatio(refBounds, courtBounds) + const { x, y } = ratioWithinBase(refBounds, courtBounds) setContent((content) => { return { @@ -158,7 +170,7 @@ function EditorView({ players: [ ...content.players, { - id: "player-" + content.players.length, + id: "player-" + element.key + "-" + element.team, team: element.team, role: element.key, rightRatio: x, @@ -166,6 +178,7 @@ function EditorView({ hasBall: false, }, ], + actions: content.actions, } }) } @@ -177,7 +190,7 @@ function EditorView({ const refBounds = ref.getBoundingClientRect() const courtBounds = courtDivContentRef.current!.getBoundingClientRect() - const { x, y } = calculateRatio(refBounds, courtBounds) + const { x, y } = ratioWithinBase(refBounds, courtBounds) let courtObject: CourtObject @@ -242,16 +255,42 @@ function EditorView({ return -1 } + function updateActions(actions: Action[], players: Player[]) { + return actions.map((action) => { + const originHasBall = players.find( + (p) => p.id == action.fromPlayerId, + )!.hasBall + + let type = action.type + + if (originHasBall && type == ActionKind.MOVE) { + type = ActionKind.DRIBBLE + } else if (originHasBall && type == ActionKind.SCREEN) { + type = ActionKind.SHOOT + } else if (type == ActionKind.DRIBBLE) { + type = ActionKind.MOVE + } else if (type == ActionKind.SHOOT) { + type = ActionKind.SCREEN + } + return { + ...action, + type, + } + }) + } + const onBallDropOnPlayer = (playerCollidedIdx: number) => { setContent((content) => { const ballObj = content.objects.findIndex((o) => o.type == "ball") let player = content.players.at(playerCollidedIdx) as Player + const players = content.players.toSpliced(playerCollidedIdx, 1, { + ...player, + hasBall: true, + }) return { ...content, - players: content.players.toSpliced(playerCollidedIdx, 1, { - ...player, - hasBall: true, - }), + actions: updateActions(content.actions, players), + players, objects: content.objects.toSpliced(ballObj, 1), } }) @@ -282,7 +321,7 @@ function EditorView({ } const courtBounds = courtDivContentRef.current!.getBoundingClientRect() - const { x, y } = calculateRatio(refBounds, courtBounds) + const { x, y } = ratioWithinBase(refBounds, courtBounds) let courtObject: CourtObject courtObject = { @@ -291,13 +330,16 @@ function EditorView({ bottomRatio: y, } + const players = content.players.map((player) => ({ + ...player, + hasBall: false, + })) + setContent((content) => { return { ...content, - players: content.players.map((player) => ({ - ...player, - hasBall: false, - })), + actions: updateActions(content.actions, players), + players, objects: [...content.objects, courtObject], } }) @@ -308,6 +350,10 @@ 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) { @@ -362,7 +408,7 @@ function EditorView({ }} />
-
+
@@ -418,11 +464,43 @@ function EditorView({ } courtRef={courtDivContentRef} + setActions={(actions) => + setContent((content) => ({ + ...content, + players: content.players, + actions: actions(content.actions), + })) + } + renderAction={(action, i) => ( + { + setContent((content) => ({ + ...content, + actions: content.actions.toSpliced( + i, + 1, + ), + })) + }} + onActionChanges={(a) => + setContent((content) => ({ + ...content, + actions: content.actions.toSpliced( + i, + 1, + a, + ), + })) + } + /> + )} onPlayerChange={(player) => { const playerBounds = document .getElementById(player.id)! @@ -440,12 +518,8 @@ function EditorView({ ), })) }} - onPlayerRemove={(player) => { - removePlayer(player) - }} - onBallRemove={() => { - removeCourtBall() - }} + onPlayerRemove={removePlayer} + onBallRemove={removeCourtBall} />
@@ -468,6 +542,18 @@ function renderCourtObject(courtObject: RackedCourtObject) { throw new Error("unknown racked court object ", courtObject.key) } +function Court({ courtType }: { courtType: string }) { + return ( +
+ {courtType == "PLAIN" ? ( + + ) : ( + + )} +
+ ) +} + function getRackPlayers(team: Team, players: Player[]): RackedPlayer[] { return ["1", "2", "3", "4", "5"] .filter( @@ -478,6 +564,19 @@ 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, reject) => { + task = setTimeout(() => f(args).then(resolve).catch(reject), delay) + }) + } +} + function useContentState( initialContent: S, initialSaveState: SaveState, diff --git a/front/views/editor/CourtAction.tsx b/front/views/editor/CourtAction.tsx new file mode 100644 index 0000000..b4028ff --- /dev/null +++ b/front/views/editor/CourtAction.tsx @@ -0,0 +1,58 @@ +import { Action, ActionKind } from "../../tactic/Action" +import BendableArrow from "../../components/arrows/BendableArrow" +import { RefObject } from "react" +import { MoveToHead, ScreenHead } from "../../components/actions/ArrowAction" + +export interface CourtActionProps { + action: Action + onActionChanges: (a: Action) => void + onActionDeleted: () => void + courtRef: RefObject +} + +export function CourtAction({ + action, + onActionChanges, + onActionDeleted, + courtRef, +}: CourtActionProps) { + let head + switch (action.type) { + case ActionKind.DRIBBLE: + case ActionKind.MOVE: + case ActionKind.SHOOT: + head = () => + break + case ActionKind.SCREEN: + head = () => + break + } + + let dashArray + switch (action.type) { + case ActionKind.SHOOT: + dashArray = "10 5" + break + } + + return ( + { + onActionChanges({ ...action, segments: edges }) + }} + wavy={action.type == ActionKind.DRIBBLE} + //TODO place those magic values in constants + endRadius={action.toPlayerId ? 26 : 17} + startRadius={0} + onDeleteRequested={onActionDeleted} + style={{ + head, + dashArray, + }} + /> + ) +} diff --git a/package.json b/package.json index 79c9d46..1380d59 100644 --- a/package.json +++ b/package.json @@ -32,8 +32,8 @@ }, "devDependencies": { "@vitejs/plugin-react": "^4.1.0", - "vite-plugin-svgr": "^4.1.0", "prettier": "^3.1.0", - "typescript": "^5.2.2" + "typescript": "^5.2.2", + "vite-plugin-svgr": "^4.1.0" } } diff --git a/sql/setup-tables.sql b/sql/setup-tables.sql index 0cbe32b..633081f 100644 --- a/sql/setup-tables.sql +++ b/sql/setup-tables.sql @@ -16,10 +16,10 @@ CREATE TABLE Account CREATE TABLE Tactic ( id integer PRIMARY KEY AUTOINCREMENT, - name varchar NOT NULL, - creation_date timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, - owner integer NOT NULL, - content varchar DEFAULT '{"players": [], "objects": []}' NOT NULL, + name varchar NOT NULL, + creation_date timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, + owner integer NOT NULL, + content varchar DEFAULT '{"players": [], "actions": [], "objects": []}' NOT NULL, court_type varchar CHECK ( court_type IN ('HALF', 'PLAIN')) NOT NULL, FOREIGN KEY (owner) REFERENCES Account ); diff --git a/src/App/Controller/EditorController.php b/src/App/Controller/EditorController.php index 8415cdb..5561590 100644 --- a/src/App/Controller/EditorController.php +++ b/src/App/Controller/EditorController.php @@ -42,7 +42,7 @@ class EditorController { return ViewHttpResponse::react("views/Editor.tsx", [ "id" => -1, //-1 id means that the editor will not support saves "name" => TacticModel::TACTIC_DEFAULT_NAME, - "content" => '{"players": [], "objects": []}', + "content" => '{"players": [], "objects": [], "actions": []}', "courtType" => $courtType->name(), ]); } diff --git a/src/App/react-display-file.php b/src/App/react-display-file.php index 46b039f..7b09417 100755 --- a/src/App/react-display-file.php +++ b/src/App/react-display-file.php @@ -17,6 +17,7 @@ "> + @@ -30,6 +31,7 @@ height: 100%; width: 100%; margin: 0; + overflow: hidden; }