add arrows visualisation

maxime 1 year ago
parent efe0540295
commit a9d6e20eaf

@ -98,5 +98,5 @@ fun App(service: IQBallService) {
auth.getOrNull()!!.token
}
VisualizerPage(service, auth, 4)
VisualizerPage(service, auth, 21)
}

@ -0,0 +1,330 @@
package com.iqball.app.component
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import arrow.core.Either
import com.iqball.app.geo.Vector
import com.iqball.app.model.tactic.Action
import com.iqball.app.model.tactic.ActionType
import com.iqball.app.model.tactic.ComponentId
import com.iqball.app.model.tactic.Segment
import com.iqball.app.model.tactic.StepContent
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.sin
private const val ArrowWidthPx = 5F
private const val ArrowHeadHeightPx = 35F
fun drawActions(
drawer: ContentDrawScope,
content: StepContent,
offsets: Map<String, Vector>,
area: Rect,
playersPixelsRadius: Float
) {
for (component in content.components) {
val originPos = offsets[component.id]!!
for (action in component.actions) {
val type = action.type
val forceStraight = type == ActionType.Shoot
val strokeStyle = when (type) {
ActionType.Screen, ActionType.Move, ActionType.Dribble -> Stroke(width = ArrowWidthPx)
ActionType.Shoot -> Stroke(
width = ArrowWidthPx,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(15F, 15F))
)
}
// draw the arrow body
if (forceStraight) {
val targetOffset = when (val value = action.target) {
is Either.Left -> offsets[value.value]!!
is Either.Right -> value.value.posWithinArea(area)
}
val path = Path()
val pathStart = constraintInCircle(
originPos,
getOrientationPointFromSegmentBounds(
action.segments,
originPos,
offsets,
area,
false
),
playersPixelsRadius
)
val pathEnd = constraintInCircle(
targetOffset,
getOrientationPointFromSegmentBounds(
action.segments,
originPos,
offsets,
area,
true
),
playersPixelsRadius + ArrowHeadHeightPx
)
path.moveTo(pathStart.x, pathStart.y)
path.lineTo(pathEnd.x, pathEnd.y)
drawer.drawPath(
path = path,
color = Color.Black,
style = strokeStyle
)
} else {
drawer.drawPath(
path = computeSegmentsToPath(
originPos,
action.segments,
offsets,
area,
type == ActionType.Dribble,
playersPixelsRadius
),
color = Color.Black,
style = strokeStyle
)
}
drawArrowHead(drawer, originPos, action, offsets, area, playersPixelsRadius)
}
}
}
private fun drawArrowHead(
drawer: DrawScope,
originPos: Vector,
action: Action,
offsets: Map<ComponentId, Vector>,
area: Rect,
playersPixelsRadius: Float
) {
val segments = action.segments
val lastSegment = segments.last()
val target = lastSegment.next
var targetPos = extractPos(target, offsets, area)
val segmentOrientationPoint =
getOrientationPointFromSegmentBounds(segments, originPos, offsets, area, true)
targetPos = constraintInCircle(targetPos, segmentOrientationPoint, playersPixelsRadius)
val pathAngleToTarget = -targetPos.angleDegWith(segmentOrientationPoint)
drawer.rotate(pathAngleToTarget, pivot = targetPos.toOffset()) {
val path =
if (action.type == ActionType.Screen) getRectangleHeadPath(targetPos) else getTriangleHeadPath(
targetPos
)
drawPath(path = path, color = Color.Black, style = Fill)
}
}
private fun getTriangleHeadPath(start: Vector): Path {
val path = Path()
path.moveTo(start.x, start.y)
path.relativeLineTo(-ArrowHeadHeightPx, -ArrowHeadHeightPx)
path.relativeLineTo(ArrowHeadHeightPx * 2, 0F)
return path
}
private fun getRectangleHeadPath(start: Vector): Path {
val path = Path()
path.moveTo(start.x, start.y - ArrowHeadHeightPx / 2F)
path.relativeLineTo(-ArrowHeadHeightPx, 0F)
path.relativeLineTo(0F, -ArrowHeadHeightPx / 2F)
path.relativeLineTo(ArrowHeadHeightPx * 2, 0F)
path.relativeLineTo(0F, ArrowHeadHeightPx / 2F)
return path
}
private fun getOrientationPointFromSegmentBounds(
segments: List<Segment>,
originPos: Vector,
offsets: Map<ComponentId, Vector>,
area: Rect,
head: Boolean
): Vector {
val boundSegment = if (head) segments.last() else segments.first()
return boundSegment
.controlPoint?.posWithinArea(area)
?: if (segments.size == 1)
if (head) originPos else extractPos(segments.last().next, offsets, area)
else run {
val referenceSegment = if (head) segments[segments.size - 2] else segments[1]
extractPos(referenceSegment.next, offsets, area)
}
}
private fun computeSegmentsToPath(
originPos: Vector,
segments: List<Segment>,
offsets: Map<ComponentId, Vector>,
area: Rect,
wavy: Boolean,
playersPixelsRadius: Float
): Path {
val path = Path()
val firstSegment = segments.first()
var segmentStart = constraintInCircle(
originPos,
getOrientationPointFromSegmentBounds(segments, originPos, offsets, area, false),
playersPixelsRadius
)
var lastSegmentCp =
firstSegment.controlPoint?.posWithinArea(area) ?: (segmentStart + extractPos(
firstSegment.next, offsets, area
) / 2F)
path.moveTo(segmentStart.x, segmentStart.y)
for (i in segments.indices) {
val segment = segments[i]
var nextPos = extractPos(segment.next, offsets, area)
if (i == segments.size - 1) {
// if it is the last segment, the next position must be constrained to the player's radius
nextPos = constraintInCircle(
nextPos,
getOrientationPointFromSegmentBounds(
segments,
originPos,
offsets,
area,
true
),
playersPixelsRadius + ArrowHeadHeightPx
)
}
val segmentCp = segment.controlPoint?.posWithinArea(area) ?: ((segmentStart + nextPos) / 2F)
val castedCp =
if (i == 0) segmentCp else segmentStart + (segmentStart - lastSegmentCp)
if (wavy) {
wavyBezier(segmentStart, castedCp, segmentCp, nextPos, 7, 15, path)
} else {
path.cubicTo(castedCp.x, castedCp.y, segmentCp.x, segmentCp.y, nextPos.x, nextPos.y)
}
lastSegmentCp = segmentCp
segmentStart = nextPos
}
return path
}
private fun cubicBeziersDerivative(
start: Vector,
cp1: Vector,
cp2: Vector,
end: Vector,
t: Float
): Vector = ((cp1 - start) * 3F * (1 - t).pow(2)) +
((cp2 - cp1) * 6F * (1 - t) * t) +
((end - cp2) * 3F * t.pow(2))
private fun cubicBeziers(
start: Vector,
cp1: Vector,
cp2: Vector,
end: Vector,
t: Float
): Vector = (start * (1 - t).pow(3)) +
(cp1 * 3F * t * (1 - t).pow(2)) +
(cp2 * 3F * t.pow(2) * (1 - t)) +
(end * t.pow(3))
private fun wavyBezier(
start: Vector,
cp1: Vector,
cp2: Vector,
end: Vector,
wavesPer100Px: Int,
amplitude: Int,
path: Path
) {
fun getVerticalDerivativeProjectionAmplification(t: Float): Vector {
val velocity = cubicBeziersDerivative(start, cp1, cp2, end, t)
val velocityLength = velocity.norm()
val projection = Vector(velocity.y, -velocity.x)
return (projection / velocityLength) * amplitude
}
val dist = start.distanceFrom(cp1) + cp1.distanceFrom(cp2) + cp2.distanceFrom(end)
val waveLength = (dist / 100) * wavesPer100Px * 2
val step = 1F / waveLength
// 0 : middle to up
// 1 : up to middle
// 2 : middle to down
// 3 : down to middle
var phase = 0
var t = step
while (t <= 1) {
val pos = cubicBeziers(start, cp1, cp2, end, t)
val amplification = getVerticalDerivativeProjectionAmplification(t)
val nextPos = when (phase) {
1, 3 -> pos
0 -> pos + amplification
else -> pos - amplification
}
val controlPointBase = cubicBeziers(start, cp1, cp2, end, t - step / 2)
val controlPoint =
if (phase == 0 || phase == 1) controlPointBase + amplification else controlPointBase - amplification
path.quadraticBezierTo(controlPoint.x, controlPoint.y, nextPos.x, nextPos.y)
phase = (phase + 1) % 4
t += step
if (t < 1 && t > 1 - step) t = 1F
}
}
/**
* 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.
*/
private fun constraintInCircle(center: Vector, reference: Vector, radius: Float): Vector {
val theta = center.angleRadWith(reference)
return Vector(
x = center.x - sin(theta) * radius,
y = center.y - cos(theta) * radius
)
}
private fun extractPos(
next: Either<ComponentId, Vector>,
offsets: Map<ComponentId, Vector>,
area: Rect
) = when (next) {
is Either.Left -> offsets[next.value]!!
is Either.Right -> next.value.posWithinArea(area)
}

@ -2,19 +2,32 @@ package com.iqball.app.component
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.iqball.app.R
import com.iqball.app.domains.getPlayerInfo
import com.iqball.app.geo.toVector
import com.iqball.app.model.tactic.BallComponent
import com.iqball.app.model.tactic.ComponentId
import com.iqball.app.model.tactic.CourtType
import com.iqball.app.model.tactic.PlayerLike
import com.iqball.app.model.tactic.StepContent
@ -31,40 +44,73 @@ fun BasketCourt(content: StepContent, type: CourtType) {
val zoomState = rememberZoomState()
val components = content.components
Row(
val componentsOffset = remember { mutableStateMapOf<ComponentId, Offset>() }
var courtArea by remember { mutableStateOf(Rect.Zero) }
val playersPixelsRadius = LocalDensity.current.run { PlayerPieceDiameterDp.dp.toPx() / 2 }
Box(
modifier = Modifier
.background(Color.LightGray)
.fillMaxSize()
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.width(IntrinsicSize.Min)
.zoomable(zoomState),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
contentAlignment = Alignment.Center,
) {
Box {
Image(
painter = painterResource(id = courtImg),
contentDescription = "court",
modifier = Modifier
.background(Color.White)
.align(Alignment.Center)
.fillMaxHeight()
.fillMaxSize()
)
Box(
modifier = Modifier
.fillMaxSize()
.onGloballyPositioned {
if (courtArea == Rect.Zero)
courtArea = it.boundsInRoot()
}
.drawWithContent {
val relativeOffsets =
componentsOffset.mapValues { (it.value - courtArea.topLeft).toVector() }
drawActions(this, content, relativeOffsets, courtArea, playersPixelsRadius)
drawContent()
}
) {
for (component in components) {
val modifier = Modifier
.onGloballyPositioned {
if (!componentsOffset.containsKey(component.id))
componentsOffset[component.id] = it.boundsInRoot().center
}
when (component) {
is PlayerLike -> {
val info = getPlayerInfo(component, content)
PlayerPiece(
player = info,
modifier = Modifier.align(info.pos.toBiasAlignment())
modifier = modifier
.align(info.pos.toBiasAlignment())
)
}
is BallComponent -> BallPiece(
modifier = Modifier.align(component.pos.toPos().toBiasAlignment())
modifier = modifier
.align(
component.pos
.toPos()
.toBiasAlignment()
)
)
}
}
}
}
}
}

@ -38,14 +38,17 @@ fun StepsTree(root: StepNodeInfo, selectedNodeId: Int, onNodeSelected: (StepNode
val scrollState = rememberFreeScrollState()
val nodesOffsets = remember { mutableStateMapOf<StepNodeInfo, Rect>() }
var globalOffset by remember { mutableStateOf(Offset(0F, 0F)) }
var globalOffset by remember { mutableStateOf(Offset.Zero) }
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.LightGray)
.freeScroll(scrollState)
.onGloballyPositioned { globalOffset = it.boundsInRoot().topLeft }
.onGloballyPositioned {
if (globalOffset == Offset.Zero)
globalOffset = it.boundsInRoot().topLeft
}
.drawWithContent {
if (nodesOffsets.isEmpty()) {
@ -56,10 +59,15 @@ fun StepsTree(root: StepNodeInfo, selectedNodeId: Int, onNodeSelected: (StepNode
val toDraw = mutableListOf(root)
while (toDraw.isNotEmpty()) {
val parent = toDraw.removeLast()
val parentCenter = nodesOffsets[parent]!!.center.minus(globalOffset)
val parentCenter = nodesOffsets[parent]!!.center - globalOffset
for (children in parent.children) {
val childrenCenter = nodesOffsets[children]!!.center.minus(globalOffset)
drawLine(Color.Black, start = parentCenter, end = childrenCenter, strokeWidth = 5F)
val childrenCenter = nodesOffsets[children]!!.center - globalOffset
drawLine(
Color.Black,
start = parentCenter,
end = childrenCenter,
strokeWidth = 5F
)
toDraw += children
}
}
@ -92,6 +100,7 @@ private fun StepsTreeContent(
modifier = Modifier
.padding(10.dp)
.onGloballyPositioned {
if (!nodesOffsets.containsKey(node))
nodesOffsets[node] = it.boundsInRoot()
}
)

@ -46,11 +46,11 @@ fun computePhantomPosition(phantom: PhantomComponent, content: StepContent): Vec
?.merge()
?: computeComponentPosition(phantomBefore, content)
val axisSegment = (referentPos - directionalPos)
val axisSegment = (directionalPos - referentPos)
val segmentLength = axisSegment.norm()
val projectedVector = Vector(
x = (axisSegment.x / segmentLength) * 0.05,
y = (axisSegment.y / segmentLength) * 0.05,
x = (axisSegment.x / segmentLength) * 0.05F,
y = (axisSegment.y / segmentLength) * 0.05F,
)
referentPos + projectedVector

@ -1,3 +0,0 @@
package com.iqball.app.geo
data class Area(val pos: Vector, val width: Float, val height: Float)

@ -1,25 +1,45 @@
package com.iqball.app.geo
import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import kotlin.math.atan2
import kotlin.math.sqrt
typealias Pos = Vector
data class Vector(val x: Double, val y: Double) {
data class Vector(val x: Float, val y: Float) {
fun toBiasAlignment(): BiasAlignment =
BiasAlignment((x * 2 - 1).toFloat(), (y * 2 - 1).toFloat())
BiasAlignment((x * 2 - 1), (y * 2 - 1))
infix operator fun minus(other: Vector) = Vector(x - other.x, y - other.y)
infix operator fun plus(other: Vector) = Vector(x + other.x, y + other.y)
infix operator fun div(other: Vector) = Vector(x / other.x, y / other.y)
infix operator fun div(n: Float) = Vector(x / n, y / n)
fun distanceWith(other: Vector) =
infix operator fun times(other: Vector) = Vector(x * other.x, y * other.y)
infix operator fun times(n: Float) = Vector(x * n, y * n)
infix operator fun times(n: Int) = Vector(x * n, y * n)
fun angleRadWith(other: Vector): Float {
val (x, y) = this - other
return atan2(x, y)
}
fun angleDegWith(other: Vector): Float = (angleRadWith(other) * (180 / Math.PI)).toFloat()
fun distanceFrom(other: Vector) =
sqrt(((x - other.x) * (x - other.x) + (y - other.y) * (y - other.y)))
fun norm() = NullVector.distanceWith(this)
fun norm() = NullVector.distanceFrom(this)
fun posWithinArea(area: Area) = Vector(x * area.width, y * area.height)
fun ratioWithinArea(area: Area) =
Vector((x - area.pos.x) * area.width, (y - area.pos.y) * area.height)
fun posWithinArea(area: Rect) = Vector(x * area.width, y * area.height)
fun toOffset() = Offset(x, y)
}
val NullVector = Vector(.0, .0)
val NullVector = Vector(0F, 0F)
fun Offset.toVector() = Vector(x, y)

@ -1,6 +1,7 @@
package com.iqball.app.model.tactic
import arrow.core.Either
import arrow.core.NonEmptyList
import com.iqball.app.geo.Vector

@ -4,6 +4,6 @@ import com.iqball.app.geo.Pos
sealed interface Positioning
data class RelativePositioning(val attach: ComponentId) : Positioning
data class FixedPosition(val x: Double, val y: Double) : Positioning {
data class FixedPosition(val x: Float, val y: Float) : Positioning {
fun toPos() = Pos(x, y)
}

@ -2,27 +2,46 @@ package com.iqball.app.serialization
import arrow.core.Either
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonDataException
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonWriter
import com.squareup.moshi.Moshi
import com.squareup.moshi.rawType
import java.lang.ClassCastException
import java.lang.reflect.ParameterizedType
import java.lang.reflect.Type
private class EitherTypeAdapter(val leftType: JsonPrimitiveType, val rightType: JsonPrimitiveType) :
JsonAdapter<Either<*, *>>() {
private class EitherTypeAdapter(
val leftType: Class<*>,
val rightType: Class<*>,
private val moshi: Moshi
) : JsonAdapter<Either<*, *>>() {
private val leftJsonType = leftType.getJsonPrimitive()
private val rightJsonType = rightType.getJsonPrimitive()
override fun fromJson(reader: JsonReader): Either<*, *>? {
val value = reader.readJsonValue() ?: return null
val valueJsonType = value.javaClass.getJsonPrimitive()
if (valueJsonType == leftType)
val valueJsonType = when (val token = reader.peek()) {
JsonReader.Token.BEGIN_ARRAY -> JsonPrimitiveType.Array
JsonReader.Token.BEGIN_OBJECT -> JsonPrimitiveType.Object
JsonReader.Token.STRING -> JsonPrimitiveType.String
JsonReader.Token.NUMBER -> JsonPrimitiveType.Number
JsonReader.Token.BOOLEAN -> JsonPrimitiveType.Boolean
JsonReader.Token.NULL -> return null
else -> throw JsonDataException("unexpected token : $token")
}
if (valueJsonType == leftJsonType) {
val value = moshi.adapter<Any>(leftType).fromJson(reader)
return Either.Left(value)
}
if (valueJsonType == rightType)
if (valueJsonType == rightJsonType) {
val value = moshi.adapter<Any>(rightType).fromJson(reader)
return Either.Right(value)
throw ClassCastException("Cannot cast a value of type " + value.javaClass + " as either " + leftType.name.lowercase() + " or " + rightType.name.lowercase())
}
throw ClassCastException("Cannot cast a json value of type " + valueJsonType + " as either " + leftType.name.lowercase() + " or " + rightType.name.lowercase())
}
@ -47,12 +66,12 @@ object EitherTypeAdapterFactory: JsonAdapter.Factory {
return null
if (type.rawType != Either::class.java)
return null
val leftType = type.actualTypeArguments[0].rawType.getJsonPrimitive()
val rightType = type.actualTypeArguments[1].rawType.getJsonPrimitive()
if (leftType == rightType) {
val leftType = type.actualTypeArguments[0].rawType
val rightType = type.actualTypeArguments[1].rawType
if (leftType.getJsonPrimitive() == rightType.getJsonPrimitive()) {
throw UnsupportedOperationException("Cannot handle Either types with both sides being object, array, string or number. Provided type is : $type")
}
return EitherTypeAdapter(leftType, rightType)
return EitherTypeAdapter(leftType, rightType, moshi)
}
}
@ -60,12 +79,13 @@ private enum class JsonPrimitiveType {
Array,
Object,
String,
Number;
Number,
Boolean
}
private fun Class<*>.getJsonPrimitive(): JsonPrimitiveType {
if (isPrimitive)
return JsonPrimitiveType.Number
return if (java.lang.Boolean.TYPE == this) JsonPrimitiveType.Boolean else JsonPrimitiveType.Number
if (isArray)
return JsonPrimitiveType.Array
if (this == String::class.java)

@ -5,6 +5,7 @@ composeFreeScroll = "0.2.2"
converterGson = "2.9.0"
converterMoshi = "2.5.0"
converterMoshiVersion = "2.5.0"
graphicsShapes = "1.0.0-alpha05"
kotlin = "1.9.0"
coreKtx = "1.12.0"
junit = "4.13.2"
@ -25,6 +26,7 @@ zoomable = "1.6.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-graphics-shapes = { module = "androidx.graphics:graphics-shapes", version.ref = "graphicsShapes" }
arrow-core = { module = "io.arrow-kt:arrow-core", version.ref = "arrowCore" }
compose-free-scroll = { module = "com.github.chihsuanwu:compose-free-scroll", version.ref = "composeFreeScroll" }
converter-moshi-v250 = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "converterMoshiVersion" }

Loading…
Cancel
Save