Merge branch 'master' of codefirst.iut.uca.fr:IQBall/Application-Web into home-page
continuous-integration/drone/push Build is passing Details

pull/81/head
DahmaneYanis 1 year ago
commit b48eb385a9

1
.gitignore vendored

@ -8,6 +8,7 @@ vendor
composer.lock
*.phar
/dist
.guard
# sqlite database files
*.sqlite

@ -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 }
}

@ -1,5 +1,4 @@
<svg width="567" height="269" viewBox="0 0 567 269" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="55" width="457" height="269" fill="#D9D9D9"/>
<line x1="73" y1="24" x2="495" y2="24" stroke="black" stroke-width="2"/>
<line x1="494" y1="23" x2="494" y2="247" stroke="black" stroke-width="2"/>
<line x1="495" y1="248" x2="73" y2="248" stroke="black" stroke-width="2"/>

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="512" height="512" x="0" y="0" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve" class=""> <g transform="matrix(1.0899999999999992,0,0,1.0899999999999992,-23.040010986327673,-23.03998867034892)"> <path fill-rule="evenodd" d="M250.131 307.122 424.673 132.58v73.421c0 17.6 14.4 31.999 31.999 31.999 17.6 0 32-14.399 32-31.999V23.328H305.998c-17.6 0-31.999 14.4-31.999 31.999 0 17.6 14.399 32 31.999 32h73.421L204.935 261.81c-39.932 40.129-110.352 12.463-110.352-45.627 0-35.683 28.926-64.609 64.609-64.609 30.018 0 55.252 20.472 62.508 48.216l48.526-48.526c-22.324-38.099-63.688-63.689-111.034-63.689-71.028 0-128.608 57.58-128.608 128.608.001 115.408 139.655 170.832 219.547 90.939zm-149.25 58.743c25.733 10.037 53.881 13.203 81.205 9.303l-104.17 104.17c-12.445 12.445-32.809 12.445-45.254 0s-12.445-32.809 0-45.254z" clip-rule="evenodd" fill="#F00" opacity="1" data-original="#F00" class=""> </path> </g> </svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@ -1,4 +1,4 @@
<svg width="80" height="49" viewBox="0 0 80 49" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 80 49" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M24.5 4.5H55.5C66.5457 4.5 75.5 13.4543 75.5 24.5C75.5 35.5457 66.5457 44.5 55.5 44.5H24.5C13.4543 44.5 4.5 35.5457 4.5 24.5C4.5 13.4543 13.4543 4.5 24.5 4.5Z"
stroke="black" stroke-width="9"/>
<line x1="24.5" y1="24.5" x2="55.5" y2="24.5" stroke="black" stroke-width="9" stroke-linecap="round"/>

Before

Width:  |  Height:  |  Size: 427 B

After

Width:  |  Height:  |  Size: 405 B

@ -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()
}}
/>

@ -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<HTMLDivElement>(null)
return (
<div className="arrow-action">
<img className="arrow-action-icon" src={arrowPng} alt="add arrow" />
<Draggable
nodeRef={arrowHeadRef}
onStart={() => {
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 }}>
<div ref={arrowHeadRef} className="arrow-head-pick" />
</Draggable>
</div>
)
}
export function ScreenHead() {
return (
<div
style={{ backgroundColor: "black", height: "5px", width: "25px" }}
/>
)
}
export function MoveToHead() {
return (
<svg viewBox={"0 0 50 50"} width={20} height={20}>
<polygon points={"50 0, 0 0, 25 40"} fill="#000" />
</svg>
)
}

@ -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<HTMLDivElement>(null)
return (
<Draggable onStop={() => onDrop(ref.current!)} nodeRef={ref}>
<div ref={ref}>
<BallPiece />
</div>
</Draggable>
)
}

@ -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<HTMLElement>
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<HTMLDivElement>(null)
const svgRef = useRef<SVGSVGElement>(null)
const pathRef = useRef<SVGPathElement>(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<FullSegment[]>(
() => 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<HTMLDivElement>(null)
const tailRef = useRef<HTMLDivElement>(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
<ArrowPoint
key={i}
className={"arrow-point-control"}
posRatio={cpPos}
parentBase={parentBase}
onPosValidated={setControlPointPos}
onRemove={() => 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 && (
<ArrowPoint
key={i + "-2"}
className={"arrow-point-next"}
posRatio={next}
parentBase={parentBase}
onPosValidated={(next) => {
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 (
<div
ref={containerRef}
style={{ position: "absolute", top: 0, left: 0 }}>
<svg
ref={svgRef}
style={{
overflow: "visible",
position: "absolute",
pointerEvents: "none",
}}>
<path
className="arrow-path"
ref={pathRef}
stroke={"#000"}
strokeWidth={styleWidth}
strokeDasharray={
style?.dashArray ?? ArrowStyleDefaults.dashArray
}
fill="none"
tabIndex={0}
onDoubleClick={addSegment}
onKeyUp={(e) => {
if (onDeleteRequested && e.key == "Delete")
onDeleteRequested()
}}
/>
</svg>
<div
className={"arrow-head"}
style={{ position: "absolute", transformOrigin: "center" }}
ref={headRef}>
{style?.head?.call(style)}
</div>
<div
className={"arrow-tail"}
style={{ position: "absolute", transformOrigin: "center" }}
ref={tailRef}>
{style?.tail?.call(style)}
</div>
{!forceStraight &&
isSelected &&
computePoints(area.current!.getBoundingClientRect())}
</div>
)
}
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<HTMLDivElement>(null)
const pos = posWithinBase(posRatio, parentBase)
return (
<Draggable
nodeRef={ref}
onStop={() => {
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 }}>
<div
ref={ref}
className={`arrow-point ${className}`}
style={{
position: "absolute",
width: radius * 2,
height: radius * 2,
}}
onKeyUp={(e) => {
if (e.key == "Delete") {
onRemove()
}
}}
tabIndex={0}
/>
</Draggable>
)
}

@ -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
)
}

@ -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,
}
}

@ -1,13 +1,6 @@
import "../../style/ball.css"
import BallSvg from "../../assets/icon/ball.svg?react"
import { Ball } from "../../tactic/CourtObjects"
export interface CourtBallProps {
onMoved: (rect: DOMRect) => void
onRemove: () => void
ball: Ball
}
export function BallPiece() {
return <BallSvg className={"ball"} />

@ -1,54 +1,248 @@
import "../../style/basket_court.css"
import { RefObject } from "react"
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<HTMLDivElement>
}
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<Action | null>(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<Action[]>([])
useLayoutEffect(() => setInternActions(actions), [actions])
return (
<div
id="court-container"
className="court-container"
ref={courtRef}
style={{ position: "relative" }}>
<img src={courtImage} alt={"court"} id="court-svg" />
{players.map((player) => {
return (
<CourtPlayer
key={player.team + player.role}
player={player}
onChange={onPlayerChange}
onRemove={() => onPlayerRemove(player)}
onBallDrop={onBallMoved}
parentRef={courtRef}
/>
)
})}
{courtImage}
{players.map((player) => (
<CourtPlayer
key={player.id}
player={player}
onDrag={() => updateActionsRelatedTo(player)}
onChange={onPlayerChange}
onRemove={() => onPlayerRemove(player)}
courtRef={courtRef}
availableActions={(pieceRef) => [
<ArrowAction
key={1}
onHeadMoved={(headPos) => {
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 && (
<BallAction
key={2}
onDrop={(ref) =>
onBallMoved(ref.getBoundingClientRect())
}
/>
),
]}
/>
))}
{internActions.map((action, idx) => renderAction(action, idx))}
{objects.map((object) => {
if (object.type == "ball") {
@ -63,6 +257,16 @@ export function BasketCourt({
}
throw new Error("unknown court object", object.type)
})}
{previewAction && (
<CourtAction
courtRef={courtRef}
action={previewAction}
//do nothing on change, not really possible as it's a preview arrow
onActionDeleted={() => {}}
onActionChanges={() => {}}
/>
)}
</div>
)
}

@ -1,6 +1,13 @@
import React, { useRef } from "react"
import Draggable from "react-draggable"
import { BallPiece, CourtBallProps } from "./BallPiece"
import { BallPiece } from "./BallPiece"
import { Ball } from "../../tactic/CourtObjects"
export interface CourtBallProps {
onMoved: (rect: DOMRect) => void
onRemove: () => void
ball: Ball
}
export function CourtBall({ onMoved, ball, onRemove }: CourtBallProps) {
const pieceRef = useRef<HTMLDivElement>(null)

@ -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<HTMLDivElement>
courtRef: RefObject<HTMLElement>
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<HTMLDivElement>(null)
const ballPiece = useRef<HTMLDivElement>(null)
const hasBall = player.hasBall
const x = player.rightRatio
const y = player.bottomRatio
const hasBall = player.hasBall
const pieceRef = useRef<HTMLDivElement>(null)
return (
<Draggable
handle={".player-piece"}
handle=".player-piece"
nodeRef={pieceRef}
position={{ x, y }}
onDrag={onDrag}
//The piece is positioned using top/bottom style attributes instead
position={NULL_POS}
onStop={() => {
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({
})
}}>
<div
id={player.id}
ref={pieceRef}
className={"player"}
className="player"
style={{
position: "absolute",
left: `${x * 100}%`,
top: `${y * 100}%`,
}}>
<div
id={player.id}
tabIndex={0}
className="player-content"
onKeyUp={(e) => {
if (e.key == "Delete") onRemove()
}}>
<div className="player-selection-tab">
{hasBall && (
<Draggable
nodeRef={ballPiece}
onStop={() =>
onBallDrop(
ballPiece.current!.getBoundingClientRect(),
)
}
position={{ x: 0, y: 0 }}>
<div ref={ballPiece}>
<BallPiece />
</div>
</Draggable>
)}
<div className="player-actions">
{availableActions(pieceRef.current!)}
</div>
<PlayerPiece
team={player.team}

@ -1,4 +1,3 @@
import React from "react"
import "../../style/player.css"
import { Team } from "../../tactic/Team"

@ -0,0 +1,23 @@
.arrow-action {
height: 50%;
}
.arrow-action-icon {
user-select: none;
-moz-user-select: none;
max-width: 17px;
max-height: 17px;
}
.arrow-head-pick {
position: absolute;
cursor: grab;
top: 0;
left: 0;
min-width: 17px;
min-height: 17px;
}
.arrow-head-pick:active {
cursor: crosshair;
}

@ -0,0 +1,14 @@
.remove-action {
height: 100%;
}
.remove-action * {
stroke: red;
fill: white;
}
.remove-action:hover * {
fill: #f1dbdb;
stroke: #ff331a;
cursor: pointer;
}

@ -1,20 +0,0 @@
#court-container {
display: flex;
align-content: center;
align-items: center;
justify-content: center;
height: 100%;
background-color: var(--main-color);
}
#court-svg {
margin: 35px 0 35px 0;
height: 87%;
user-select: none;
-webkit-user-drag: none;
}
#court-svg * {
stroke: var(--selected-team-secondarycolor);
}

@ -0,0 +1,22 @@
.arrow-point {
cursor: pointer;
border-radius: 100px;
background-color: black;
outline: none;
}
.arrow-point:hover {
background-color: var(--selection-color);
}
.arrow-path {
pointer-events: stroke;
cursor: pointer;
outline: none;
}
.arrow-path:hover,
.arrow-path:active {
stroke: var(--selection-color);
}

@ -0,0 +1,13 @@
:root {
--main-color: #ffffff;
--second-color: #ccde54;
--background-color: #d2cdd3;
--selected-team-primarycolor: #ffffff;
--selected-team-secondarycolor: #000000;
--selection-color: #3f7fc4;
--arrows-color: #676767;
}

@ -7,6 +7,8 @@
background-color: var(--background-color);
flex-direction: column;
overflow: hidden;
}
#topbar-left {
@ -45,21 +47,20 @@
height: 100%;
}
#allies-rack {
#allies-rack,
#opponent-rack {
width: 125px;
min-width: 125px;
display: flex;
flex-direction: row;
align-items: flex-end;
}
#allies-rack {
justify-content: flex-start;
}
#opponent-rack {
width: 125px;
min-width: 125px;
display: flex;
flex-direction: row;
align-items: flex-end;
justify-content: flex-end;
}
@ -81,7 +82,9 @@
#court-div {
background-color: var(--background-color);
height: 100%;
width: 100%;
display: flex;
align-items: center;
@ -89,11 +92,33 @@
align-content: center;
}
#court-div-bounds {
padding: 20px 20px 20px 20px;
#court-image-div {
position: relative;
background-color: white;
height: 100%;
width: 100%;
}
.court-container {
display: flex;
align-content: center;
align-items: center;
justify-content: center;
height: 75%;
}
#court-image {
height: 100%;
width: 100%;
user-select: none;
-webkit-user-drag: none;
}
#court-image * {
stroke: var(--selected-team-secondarycolor);
}
.react-draggable {
z-index: 2;
}

@ -1,9 +1,3 @@
/**
as the .player div content is translated,
the real .player div position is not were the user can expect.
Disable pointer events to this div as it may overlap on other components
on the court.
*/
.player {
pointer-events: none;
}
@ -42,38 +36,27 @@ on the court.
border-color: var(--player-piece-ball-border-color);
}
.player-selection-tab {
display: none;
.player-actions {
display: flex;
position: absolute;
margin-bottom: -20%;
justify-content: center;
width: fit-content;
transform: translateY(-20px);
}
.player-selection-tab-remove {
flex-direction: row;
justify-content: space-evenly;
align-content: space-between;
align-items: center;
visibility: hidden;
pointer-events: all;
width: 25px;
height: 17px;
justify-content: center;
}
.player-selection-tab-remove * {
stroke: red;
fill: white;
}
transform: translateY(-25px);
.player-selection-tab-remove:hover * {
fill: #f1dbdb;
stroke: #ff331a;
cursor: pointer;
height: 20px;
width: 150%;
gap: 25%;
}
.player:focus-within .player-selection-tab {
display: flex;
.player:focus-within .player-actions {
visibility: visible;
pointer-events: all;
}
.player:focus-within .player-piece {

@ -19,5 +19,6 @@
--editor-court-selection-background: #5f8fee;
--editor-court-selection-buttons: #acc4f3;
--player-piece-ball-border-color: #000000;
--text-main-font: "Roboto", sans-serif;
}

@ -0,0 +1,19 @@
import { Pos } from "../components/arrows/Pos"
import { Segment } from "../components/arrows/BendableArrow"
import { PlayerId } from "./Player"
export enum ActionKind {
SCREEN = "SCREEN",
DRIBBLE = "DRIBBLE",
MOVE = "MOVE",
SHOOT = "SHOOT",
}
export type Action = { type: ActionKind } & MovementAction
export interface MovementAction {
fromPlayerId: PlayerId
toPlayerId?: PlayerId
moveFrom: Pos
segments: Segment[]
}

@ -1,11 +0,0 @@
export interface Ball {
/**
* Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle)
*/
bottom_percentage: number
/**
* Percentage of the player's position to the right (0 means left, 1 means right, 0.5 means middle)
*/
right_percentage: number
}

@ -1,7 +1,10 @@
import { Team } from "./Team"
export type PlayerId = string
export interface Player {
readonly id: string
readonly id: PlayerId
/**
* the player's team
* */
@ -22,5 +25,8 @@ export interface Player {
*/
readonly rightRatio: number
/**
* True if the player has a basketball
*/
readonly hasBall: boolean
}

@ -1,5 +1,6 @@
import { Player } from "./Player"
import { CourtObject } from "./CourtObjects"
import { Action } from "./Action"
export interface Tactic {
id: number
@ -10,4 +11,5 @@ export interface Tactic {
export interface TacticContent {
players: Player[]
objects: CourtObject[]
actions: Action[]
}

@ -3,15 +3,14 @@ import {
Dispatch,
SetStateAction,
useCallback,
useMemo,
useRef,
useState,
} from "react"
import "../style/editor.css"
import TitleInput from "../components/TitleInput"
import { BasketCourt } from "../components/editor/BasketCourt"
import plainCourt from "../assets/court/full_court.svg"
import halfCourt from "../assets/court/half_court.svg"
import PlainCourt from "../assets/court/full_court.svg?react"
import HalfCourt from "../assets/court/half_court.svg?react"
import { BallPiece } from "../components/editor/BallPiece"
@ -22,7 +21,6 @@ import { Player } from "../tactic/Player"
import { Tactic, TacticContent } from "../tactic/Tactic"
import { fetchAPI } from "../Fetcher"
import { Team } from "../tactic/Team"
import { calculateRatio } from "../Utils"
import SavingState, {
SaveState,
@ -30,6 +28,10 @@ import SavingState, {
} from "../components/editor/SavingState"
import { CourtObject } from "../tactic/CourtObjects"
import { CourtAction } from "./editor/CourtAction"
import { BasketCourt } from "../components/editor/BasketCourt"
import { ratioWithinBase } from "../components/arrows/Pos"
import { Action, ActionKind } from "../tactic/Action"
const ERROR_STYLE: CSSProperties = {
borderColor: "red",
@ -118,7 +120,17 @@ function EditorView({
const [content, setContent, saveState] = useContentState(
initialContent,
isInGuestMode ? SaveStates.Guest : SaveStates.Ok,
onContentChange,
useMemo(
() =>
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
@ -199,12 +212,12 @@ function EditorView({
}
})
return
} else {
courtObject = {
type: "ball",
rightRatio: x,
bottomRatio: y,
}
}
courtObject = {
type: "ball",
rightRatio: x,
bottomRatio: y,
}
break
@ -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({
}}
/>
</div>
<div id="topbar-right"></div>
<div id="topbar-right" />
</div>
<div id="edit-div">
<div id="racks">
@ -418,11 +464,43 @@ function EditorView({
<BasketCourt
players={content.players}
objects={content.objects}
actions={content.actions}
onBallMoved={onBallDrop}
courtImage={
courtType == "PLAIN" ? plainCourt : halfCourt
}
courtImage={<Court courtType={courtType} />}
courtRef={courtDivContentRef}
setActions={(actions) =>
setContent((content) => ({
...content,
players: content.players,
actions: actions(content.actions),
}))
}
renderAction={(action, i) => (
<CourtAction
key={i}
action={action}
courtRef={courtDivContentRef}
onActionDeleted={() => {
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}
/>
</div>
</div>
@ -468,6 +542,18 @@ function renderCourtObject(courtObject: RackedCourtObject) {
throw new Error("unknown racked court object ", courtObject.key)
}
function Court({ courtType }: { courtType: string }) {
return (
<div id="court-image-div">
{courtType == "PLAIN" ? (
<PlainCourt id="court-image" />
) : (
<HalfCourt id="court-image" />
)}
</div>
)
}
function 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<A, B>(
f: (args: A) => Promise<B>,
delay = 1000,
): (args: A) => Promise<B> {
let task = 0
return (args: A) => {
clearTimeout(task)
return new Promise((resolve, reject) => {
task = setTimeout(() => f(args).then(resolve).catch(reject), delay)
})
}
}
function useContentState<S>(
initialContent: S,
initialSaveState: SaveState,

@ -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<HTMLElement>
}
export function CourtAction({
action,
onActionChanges,
onActionDeleted,
courtRef,
}: CourtActionProps) {
let head
switch (action.type) {
case ActionKind.DRIBBLE:
case ActionKind.MOVE:
case ActionKind.SHOOT:
head = () => <MoveToHead />
break
case ActionKind.SCREEN:
head = () => <ScreenHead />
break
}
let dashArray
switch (action.type) {
case ActionKind.SHOOT:
dashArray = "10 5"
break
}
return (
<BendableArrow
forceStraight={action.type == ActionKind.SHOOT}
area={courtRef}
startPos={action.moveFrom}
segments={action.segments}
onSegmentsChanges={(edges) => {
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,
}}
/>
)
}

@ -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"
}
}

@ -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
);

@ -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(),
]);
}

@ -17,6 +17,7 @@
</script>
<link rel="icon" href="<?= asset("assets/favicon.ico") ?>">
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
@ -30,6 +31,7 @@
height: 100%;
width: 100%;
margin: 0;
overflow: hidden;
}
</style>

Loading…
Cancel
Save