package theorycrafter.ui.graphs

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.gestures.awaitDragOrCancellation
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.LocalContentColor
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.rotateRad
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.times
import compose.utils.EasyTooltipPlacement
import compose.utils.HSpacer
import compose.utils.VSpacer
import compose.widgets.*
import eve.data.*
import eve.data.typeid.*
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.jetbrains.skiko.OS
import org.jetbrains.skiko.hostOs
import theorycrafter.FitHandle
import theorycrafter.TempFittingScope
import theorycrafter.TheorycrafterContext
import theorycrafter.fitting.*
import theorycrafter.fitting.utils.fastForEach
import theorycrafter.ui.TheorycrafterTheme
import theorycrafter.ui.shortName
import theorycrafter.ui.tooltip
import theorycrafter.ui.widgets.CheckboxedText
import theorycrafter.ui.widgets.DoubleTextField
import theorycrafter.ui.widgets.RadioButtonWithText
import theorycrafter.ui.widgets.maxPrecisionFormatter
import theorycrafter.utils.DpOffsetX
import theorycrafter.utils.TextDropdownField
import theorycrafter.utils.deg2rad
import theorycrafter.utils.rad2deg
import kotlin.math.*


/**
 * The graphs window pane for exploring the damage a fit applies to other fits.
 */
@Composable
fun DamageGraphPane(initialFitHandle: FitHandle?) {
    val targets: MutableList<DamageTarget> = rememberSaveableListOfGraphSources()
    val attackers: MutableList<GraphFit> = rememberSaveableListOfGraphSources(initialFitHandle, targets.size)

    // Whenever initialFitHandle changes (to a non-null value), make sure we have a target
    val firstColor = nextGraphSourceColor(emptyList())!!
    LaunchedEffect(initialFitHandle) {
        if (targets.isEmpty()) {
            targets.add(
                DamageTarget.Ideal(firstColor)
            )
        }
    }

    val attackersVelocity = rememberSaveable { ShipRelativeVelocityVectorState() }
    val targetsVelocity = rememberSaveable { ShipRelativeVelocityVectorState(magnitude = 1.0, direction = 0.0) }
    val settings = TheorycrafterContext.settings.graphs.damage
    val showVolley = settings.showVolley
    val includeDrones = settings.includeDrones
    val includeWreckingShots = settings.includeWreckingShots
    val ammoSelection = settings.ammoSelection
    GraphPaneScaffold(
        graph = { modifier ->
            DamageGraph(
                attackers = attackers,
                targets = targets,
                attackersVelocity = attackersVelocity,
                targetsVelocity = targetsVelocity,
                showVolley = showVolley.value,
                includeDrones = includeDrones.value,
                includeWreckingShots = includeWreckingShots.value,
                ammoSelection = ammoSelection.value,
                modifier = modifier,
            )
        },
        paramsEditor = { modifier ->
            DamageGraphParams(
                attackers = attackers,
                targets = targets,
                attackersVelocity = attackersVelocity,
                targetsVelocity = targetsVelocity,
                showVolley = showVolley,
                includeDrones = includeDrones,
                includeWreckingShots = includeWreckingShots,
                ammoSelection = ammoSelection,
                virtualTargetSignatureRadius = settings.virtuaTargetSignatureRadius,
                virtualTargetMaxVelocity = settings.virtualTargetMaxVelocity,
                modifier = modifier,
            )
        }
    )
}


/**
 * The interface for objects the user can specify as the targets of damage attacks.
 */
@Stable
interface DamageTarget: GraphSource {


    /**
     * The signature radius of the target.
     */
    val signatureRadius: Double


    /**
     * The maximum speed of the target.
     */
    val maxVelocity: Double


    /**
     * The (ball) radius of the target.
     */
    val radius: Double


    /**
     * A virtual [DamageTarget] that simply has the specified properties.
     */
    class Virtual(
        override val color: Color,
        override val signatureRadius: Double,
        override val maxVelocity: Double,
    ): DamageTarget {

        override val radius: Double
            get() = 0.0

        override val name: String
            get() = "Virtual Target"

    }


    /**
     * An ideal damage target, with 0 speed and infinite signature radius.
     */
    class Ideal(
        override val color: Color,
    ): DamageTarget {

        override val signatureRadius: Double
            get() = Double.POSITIVE_INFINITY

        override val maxVelocity: Double
            get() = 0.0

        override val radius: Double
            get() = 0.0

        override val name: String
            get() = "Ideal Target"

    }


}


/**
 * The interface for types holding the attributes needed to compute the damage of various kinds of modules.
 */
private interface DamageAttrs {
    val baseDamage: Double?
    val baseDamageOrZero: Double
        get() = baseDamage ?: 0.0
}


/**
 * The damage attributes of turrets.
 */
private data class TurretAttrs(
    override val baseDamage: Double?,
    val trackingSpeed: Double?,
    val signatureResolution: Double?,
    val optimalRange: Double?,
    val falloffRange: Double?
): DamageAttrs


/**
 * The damage attributes of missiles.
 */
private data class MissileAttrs(
    override val baseDamage: Double?,
    val range: MissileRange?,
    val explosionRadius: Double?,
    val explosionVelocity: Double?,
    val damageReductionFactor: Double?
): DamageAttrs


/**
 * The damage attributes of bombs.
 */
private data class BombAttrs(
    override val baseDamage: Double?,
    val range: MissileRange?,
    val explosionRange: Double?,
    val explosionRadius: Double?,
): DamageAttrs


/**
 * The damage attributes of vorton projectors.
 */
private data class VortonProjectorAttrs(
    override val baseDamage: Double?,
    val optimalRange: Double?,
    val explosionRadius: Double?,
    val explosionVelocity: Double?,
    val damageReductionFactor: Double?
): DamageAttrs


/**
 * The damage attributes of smartbombs.
 */
private data class SmartbombAttrs(
    override val baseDamage: Double?,
    val optimalRange: Double?
): DamageAttrs


/**
 * Computes the damage attributes of the given module when loaded with the given charge.
 */
private fun TempFittingScope.computeModuleAttrsWithCharge(
    module: Module,
    chargeType: ChargeType?,
    showVolley: Boolean,
): DamageAttrs? = runBlocking {
    if (chargeType != null) {
        modify {
            module.setCharge(chargeType)
        }
    }
    with(TheorycrafterContext.eveData) {
        val baseDamage = if (showVolley) module.volleyDamage else module.dps
        val moduleType = module.type
        when {
            moduleType.isTurret() ->
                TurretAttrs(
                    baseDamage = baseDamage,
                    trackingSpeed = module.trackingSpeed?.value,
                    signatureResolution = module.signatureResolution?.value,
                    optimalRange = module.optimalRange?.value,
                    falloffRange = module.falloffRange?.value
                )
            moduleType.isMissileLauncher() -> {
                val missile = module.loadedCharge ?: return@with null
                MissileAttrs(
                    baseDamage = baseDamage,
                    range = missile.missileRange,
                    explosionRadius = missile.missileExplosionRadius?.value,
                    explosionVelocity = missile.missileExplosionVelocity?.value,
                    damageReductionFactor = missile.missileDamageReductionFactor?.value
                )
            }
            moduleType.isBombLauncher() -> {
                val bomb = module.loadedCharge ?: return@with null
                BombAttrs(
                    baseDamage = baseDamage,
                    range = bomb.missileRange,
                    explosionRange = bomb.explosionRange?.value,
                    explosionRadius = bomb.missileExplosionRadius?.value
                )
            }
            moduleType.isVortonProjector() ->
                VortonProjectorAttrs(
                    baseDamage = baseDamage,
                    optimalRange = module.optimalRange?.value,
                    explosionRadius = module.explosionRadius?.value,
                    explosionVelocity = module.explosionVelocity?.value,
                    damageReductionFactor = module.damageReductionFactor?.value
                )
            moduleType.isSmartbomb() ->
                SmartbombAttrs(
                    baseDamage = baseDamage,
                    optimalRange = module.optimalRange?.value
                )
            else -> null
        }
    }
}


/**
 * Wraps the damage attributes for each charge, for each module, for a single attacker.
 */
private data class AttackerDamageModulesAttrs(
    val modulesWithChargeTypeAndParams: List<Pair<Module, List<Pair<ChargeType?, DamageAttrs>>>>,
)


/**
 * The graph displaying the damage on a target.
 */
@Composable
private fun DamageGraph(
    attackers: List<GraphFit>,
    targets: List<DamageTarget>,
    attackersVelocity: ShipRelativeVelocityVectorState,
    targetsVelocity: ShipRelativeVelocityVectorState,
    showVolley: Boolean,
    includeDrones: Boolean,
    includeWreckingShots: Boolean,
    ammoSelection: AmmoSelection,
    modifier: Modifier
) {
    val damageModuleAttrsByAttacker = remember { mutableStateMapOf<GraphFit, State<AttackerDamageModulesAttrs?>>() }
    for (attacker in attackers) {
        key(attacker) {
            val tempFittingScope = TheorycrafterContext.fits.rememberTempFittingScope(attacker.fit, attacker.fit.ship.type)
            damageModuleAttrsByAttacker[attacker] = produceState<AttackerDamageModulesAttrs?>(
                initialValue = null,
                attacker,
                attacker.fit.changeKey,
                ammoSelection,
                showVolley,
                tempFittingScope,
            ) {
                with(tempFittingScope) {
                    modify {
                        copyFit(source = attacker.fit, target = tempFit)
                    }
                    val damageModules = tempFit.modules.active.filter {
                        module -> module.volleyDamageByType.values.any { it != null }
                    }
                    val moduleParamsByModuleAndCharge = damageModules.map { module ->
                        val charges = if (module.canLoadCharges) {
                            with(TheorycrafterContext.eveData) {
                                ammoSelection
                                    .chargesFor(module)
                                    // Sort by damage type so that when there are several types of ammo that do the same
                                    // damage, we always return the same one when computing the best.
                                    .sortedWith(
                                        compareBy<ChargeType> { charge ->
                                            DamageType.entries.maxBy { charge.damageOfType(it) ?: 0.0 }
                                        }.thenBy { it.name }
                                    )
                            }
                        } else listOf(null)  // null for damage modules w/o charges, e.g. smartbombs

                        val chargeTypeAndModuleParams = charges.mapNotNull { charge ->
                            val moduleParams = computeModuleAttrsWithCharge(module, charge, showVolley) ?: return@mapNotNull null
                            Pair(charge, moduleParams)
                        }
                        Pair(module, chargeTypeAndModuleParams)
                    }
                    value = AttackerDamageModulesAttrs(moduleParamsByModuleAndCharge)
                }
            }
        }
    }
    for (attacker in damageModuleAttrsByAttacker.keys.toList()) {
        if (attacker !in attackers) {
            damageModuleAttrsByAttacker.remove(attacker)
        }
    }

    Row(modifier) {
        val computationByAttackerAndTarget = remember {
            mutableStateMapOf<Pair<GraphFit, DamageTarget>, DamageComputation>()
        }
        CumulativeRemoteEffectsGraph(
            fits = attackers,
            targets = targets,
            moduleFilter = { true },
            droneFilter = { drone -> drone.volleyDamage != null },
            includeDrones = includeDrones,
            computationScope = remember(showVolley, attackersVelocity, targetsVelocity, includeWreckingShots) {
                computationLambda@ { _, allDrones, attacker, target ->
                    // Ignore the modules passed to us, and use damageModuleAttrsByAttacker, once they've been computed
                    val damageModulesAttrs = damageModuleAttrsByAttacker[attacker]?.value ?: return@computationLambda null
                    val damageByDrone = allDrones.associateWith {
                        if (showVolley) it.totalVolleyDamage else it.totalDps
                    }
                    DamageComputation { drones, distance ->
                        with(TheorycrafterContext.eveData) {
                            val (moduleDamage, charges) = moduleDamageAndCharges(
                                damageModulesAttrs = damageModulesAttrs,
                                attacker = attacker,
                                target = target,
                                attackerVelocity = attackersVelocity,
                                targetVelocity = targetsVelocity,
                                distance = distance,
                                includeWreckingShots = includeWreckingShots
                            )
                            val droneDamage = droneDamage(
                                drones = drones,
                                damageByDrone = damageByDrone,
                                target = target,
                                targetVelocity = targetsVelocity,
                                distance = distance,
                                includeWreckingShots = includeWreckingShots
                            )
                            Pair(moduleDamage + droneDamage, charges)
                        }
                    }
                }
            },
            onComputationScopeChanged = { graphFit, damageTarget, damageComputation ->
                if (damageComputation is DamageComputation) {  // It can also be ZeroEffectComputation
                    computationByAttackerAndTarget[graphFit to damageTarget] = damageComputation
                }
            },
            effectValueFormatter = { if (showVolley) it.asDamage() else it.asDps() },
            maxModulesDisplayedRange = { _: List<Module>, attacker: GraphFit ->
                val modulesDamageAttrs = damageModuleAttrsByAttacker[attacker]?.value
                    ?: return@CumulativeRemoteEffectsGraph 0.0
                damageModulesMaxDisplayedRange(modulesDamageAttrs)
            },
            defaultMaxDisplayedValue = 100.0,
            maxDisplayedValueFor = { _, drones, attacker, _ ->
                val droneDamage = if (showVolley)
                    drones.sumOf { it.totalVolleyDamage ?: 0.0 }
                else
                    drones.sumOf { it.totalDps ?: 0.0 }

                val modulesDamageAttrs = damageModuleAttrsByAttacker[attacker]?.value
                val moduleDamage = modulesDamageAttrs?.modulesWithChargeTypeAndParams?.sumOf { (_, chargeAndDamageAttrs) ->
                    chargeAndDamageAttrs.maxOfOrZero { (_, damageAttrs) -> damageAttrs.baseDamage ?: 0.0 }
                } ?: 0.0

                droneDamage + moduleDamage
            },
            modifier = Modifier.weight(1f)
        )

        AnimatedVisibility(
            visible = (ammoSelection != AmmoSelection.CurrentlyLoaded) && computationByAttackerAndTarget.isNotEmpty(),
        ) {
            BestAmmoChoicePanel(
                attackers = attackers,
                targets = targets,
                computationByAttackerAndTarget = computationByAttackerAndTarget,
            )
        }
    }
}


/**
 * The panel displaying the best ammo choice for each attacker-target pair, at each range.
 */
@Composable
private fun BestAmmoChoicePanel(
    attackers: List<GraphFit>,
    targets: List<DamageTarget>,
    computationByAttackerAndTarget: SnapshotStateMap<Pair<GraphFit, DamageTarget>, DamageComputation>,
) {
    VerticallyScrollableContent {
        SelectionContainer {
            Column(Modifier.padding(horizontal = TheorycrafterTheme.spacing.horizontalEdgeMargin)) {
                SingleLineText(
                    text = "Best Ammo Choice",
                    style = TheorycrafterTheme.textStyles.mediumHeading
                )
                VSpacer(TheorycrafterTheme.spacing.large)
                val useTargetColor = useTargetLineParams(attackers.size, targets.size)
                var sectionCount = 0
                for (attacker in attackers) {
                    for (target in targets) {
                        val computation = computationByAttackerAndTarget[attacker to target] ?: continue
                        val bestAmmoAtRanges = computation.computeBestAmmoAtRanges()
                        if (bestAmmoAtRanges.all { (_, ammoList) -> ammoList.all { it == null }})
                            continue

                        sectionCount += 1

                        val color = if (useTargetColor) target.color else attacker.color
                        SingleLineText(
                            text = buildAnnotatedString {
                                withStyle(SpanStyle(color = color)) {
                                    append("■")  // black square
                                }
                                append(" ${attacker.name} on ${target.name}")
                            },
                            style = TheorycrafterTheme.textStyles.smallHeading
                        )
                        VSpacer(TheorycrafterTheme.spacing.xxxsmall)
                        for ((range, ammoList) in bestAmmoAtRanges) {
                            val nonNullAmmoList = ammoList.filterNotNull()
                            val rangeText = if (range.endExclusive == MaxRangeForBestAmmo)
                                "${range.start.toDouble().asDistance()}+"
                            else
                                "${range.start.toDouble().asDistance()}$NNBSP-$NNBSP${range.endExclusive.toDouble().asDistance()}"
                            Text("$rangeText: " + nonNullAmmoList.joinToString { it.shortName() })
                        }
                        VSpacer(TheorycrafterTheme.spacing.medium)
                    }
                }

                if (sectionCount == 0) {
                    SingleLineText("No ammo used")
                }
            }
        }
    }
}


/**
 * The interface for prepared computations of damage and best ammo choice at each distance.
 * Note that the set of modules doing the damage is "baked-into" the implementing object.
 */
private fun interface DamageComputation: CumulativeEffectComputation {

    /**
     * Returns the damage done by the modules and given drones, at the given distance, and the list of charges to load
     * into the modules for that damage.
     */
    fun damageAndChargesAtDistance(
        drones: List<DroneGroup>,
        distance: Double,
    ): Pair<Double, List<ChargeType?>>


    override fun atDistance(modules: List<Module>, drones: List<DroneGroup>, distance: Double): Double {
        return damageAndChargesAtDistance(drones, distance).first
    }


}


/**
 * The value used to mark the maximum range in [computeBestAmmoAtRanges].
 */
private const val MaxRangeForBestAmmo = 250_000


/**
 * Using a [DamageComputation], computes the best ammo choices at each distance, and returns them as a list of
 * range-ammoChoice pairs.
 */
private fun DamageComputation.computeBestAmmoAtRanges(): List<Pair<OpenEndRange<Int>, List<ChargeType?>>> {
    return buildList {
        val rangesToTest = listOf(
            100 until 2000 step 100,
            2000 .. MaxRangeForBestAmmo step 1000
        )
        var currentRangeHasDamage = false
        var currentBestCharges: List<ChargeType?> =
            damageAndChargesAtDistance(drones = emptyList(), rangesToTest.first().first.toDouble()).second
        var currentRangeStart = 0
        for (rangeToTest in rangesToTest) {
            for (distance in rangeToTest) {
                val (damage, bestCharges) = damageAndChargesAtDistance(drones = emptyList(), distance.toDouble())
                if ((bestCharges != currentBestCharges) || (currentRangeHasDamage && (damage == 0.0))) {
                    if (currentRangeHasDamage)
                        add(Pair(currentRangeStart until distance, currentBestCharges))
                    currentRangeStart = distance
                    currentBestCharges = bestCharges
                    currentRangeHasDamage = false
                }
                if (damage > 0.0)
                    currentRangeHasDamage = true
            }
        }
        if (currentRangeHasDamage)
            add(Pair(currentRangeStart until rangesToTest.last().last, currentBestCharges))
    }
}


/**
 * Returns the maximum value returned by [selector] on the items of the given list, or 0.0 if the list is empty.
 */
private inline fun <T> List<T>.maxOfOrZero(selector: (T) -> Double): Double {
    var maxValue = 0.0
    fastForEach {
        val value = selector(it)
        if (value > maxValue)
            maxValue = value
    }
    return maxValue
}


/**
 * Returns the maximum range to display on the graph for the given modules.
 */
private fun damageModulesMaxDisplayedRange(modulesAttrs: AttackerDamageModulesAttrs): Double {
    return with(TheorycrafterContext.eveData) {
        modulesAttrs.modulesWithChargeTypeAndParams.maxOfOrNull { (module, chargeTypeAndDamageAttrs) ->
            val moduleType = module.type
            when {
                moduleType.isTurret() -> chargeTypeAndDamageAttrs.maxOfOrZero { (_, damageAttrs) ->
                    val turretAttrs = damageAttrs as TurretAttrs
                    defaultModuleMaxDisplayedRange(
                        optimal = turretAttrs.optimalRange,
                        falloff = turretAttrs.falloffRange
                    )
                }
                moduleType.isMissileLauncher() -> chargeTypeAndDamageAttrs.maxOfOrZero { (_, damageAttrs) ->
                    val missileAttrs = damageAttrs as MissileAttrs
                    missileAttrs.range?.longRange ?: 0.0
                }
                moduleType.isBombLauncher() -> chargeTypeAndDamageAttrs.maxOfOrZero { (_, damageAttrs) ->
                    val bombAttrs = damageAttrs as BombAttrs
                    val explosionRange = bombAttrs.explosionRange ?: return@maxOfOrZero 0.0
                    val range = bombAttrs.range?.longRange ?: return@maxOfOrZero 0.0
                    range + explosionRange + 5000  // Add 5000 for the target radius
                }
                moduleType.isVortonProjector() -> chargeTypeAndDamageAttrs.maxOfOrZero { (_, damageAttrs) ->
                    val projectorAttrs = damageAttrs as VortonProjectorAttrs
                    defaultModuleMaxDisplayedRange(
                        optimal = projectorAttrs.optimalRange,
                        falloff = 0.0
                    )
                }
                moduleType.isSmartbomb() -> chargeTypeAndDamageAttrs.maxOfOrZero { (_, damageAttrs) ->
                    val smartbombAttrs = damageAttrs as SmartbombAttrs
                    defaultModuleMaxDisplayedRange(
                        optimal = smartbombAttrs.optimalRange,
                        falloff = 0.0
                    )
                }
                else -> 0.0
            }
        } ?: 0.0
    }
}


/**
 * Returns the visual angular velocity between an attacker and a target.
 */
private fun visualAngularVelocity(
    centersDistance: Double,
    attackerRelativeVelocity: ShipRelativeVelocityVectorState,
    attackerMaxVelocity: Double,
    targetRelativeVelocity: ShipRelativeVelocityVectorState,
    targetMaxVelocity: Double
) = angularVelocity(
    centersDistance = centersDistance,
    speed1 = attackerRelativeVelocity.magnitude * attackerMaxVelocity,
    // Rotate the vectors by 90 degrees here because `angularVelocity` assumes the target is to the right of the
    // attacker, but our target velocity widget is actually below the the attacker's velocity widget.
    direction1 = attackerRelativeVelocity.direction + Math.PI/2,
    speed2 = targetRelativeVelocity.magnitude * targetMaxVelocity,
    direction2 = targetRelativeVelocity.direction + Math.PI/2
)


/**
 * Computes the damage of the given list of modules.
 */
context(EveData)
private fun moduleDamageAndCharges(
    damageModulesAttrs: AttackerDamageModulesAttrs,
    attacker: GraphFit,
    target: DamageTarget,
    attackerVelocity: ShipRelativeVelocityVectorState,
    targetVelocity: ShipRelativeVelocityVectorState,
    distance: Double,
    includeWreckingShots: Boolean,
): Pair<Double, List<ChargeType?>> {
    val centersDistance = distance + attacker.fit.ship.radius.value + target.radius
    val angularVelocity = visualAngularVelocity(
        centersDistance = centersDistance,
        attackerRelativeVelocity = attackerVelocity,
        attackerMaxVelocity = attacker.fit.propulsion.maxVelocity,
        targetRelativeVelocity = targetVelocity,
        targetMaxVelocity = target.maxVelocity,
    )

    val damageComparator = compareBy<Pair<ChargeType?, Double>> {
        // Round the damage to an integer in order to prevent very small differences in dps due to floating-point
        // inaccuracy from affecting the outcome.
        it.second.roundToInt()
    }
    val chargeTypes = mutableListOf<ChargeType?>()
    val totalDamage = damageModulesAttrs.modulesWithChargeTypeAndParams.sumOf { (module, chargeTypeAndDamageAttrs) ->
        val moduleType = module.type
        val chargeTypeAndDamage = when {
            moduleType.isTurret() -> chargeTypeAndDamageAttrs.maxOfWithOrNull(damageComparator) { (chargeType, attrs) ->
                val turretAttrs = attrs as TurretAttrs
                val coef = turretDamageCoef(
                    turretParams = turretAttrs,
                    angularVelocity = angularVelocity,
                    centersDistance = centersDistance,
                    signatureRadius = target.signatureRadius,
                    includeWreckingShots = includeWreckingShots
                )
                val damage = coef * turretAttrs.baseDamageOrZero
                Pair(chargeType, damage)
            }
            moduleType.isMissileLauncher() -> chargeTypeAndDamageAttrs.maxOfWithOrNull(damageComparator) { (chargeType, attrs) ->
                val missileAttrs = attrs as MissileAttrs
                val coef = missileLauncherDamageCoef(
                    missileAttrs = missileAttrs,
                    target = target,
                    targetVelocity = targetVelocity,
                    distance = distance
                )
                val damage = coef * missileAttrs.baseDamageOrZero
                Pair(chargeType, damage)
            }
            moduleType.isBombLauncher() -> chargeTypeAndDamageAttrs.maxOfWithOrNull(damageComparator) { (chargeType, attrs) ->
                val bombAttrs = attrs as BombAttrs
                val damage = bombDamageCoef(bombAttrs, target, distance) * bombAttrs.baseDamageOrZero
                Pair(chargeType, damage)
            }
            moduleType.isVortonProjector() -> chargeTypeAndDamageAttrs.maxOfWithOrNull(damageComparator) { (chargeType, attrs) ->
                val projectorAttrs = attrs as VortonProjectorAttrs
                val coef = vortonProjectorDamageCoef(
                    projectorAttrs = projectorAttrs,
                    target = target,
                    targetVelocity = targetVelocity,
                    distance = distance
                )
                val damage = coef * projectorAttrs.baseDamageOrZero
                Pair(chargeType, damage)
            }
            moduleType.isSmartbomb() -> chargeTypeAndDamageAttrs.maxOfWithOrNull(damageComparator) { (chargeType, attrs) ->
                val smartbombAttrs = attrs as SmartbombAttrs
                val coef = smartbombDamageCoef(
                    smartbombAttrs = smartbombAttrs,
                    distance = distance
                )
                val damage = coef * smartbombAttrs.baseDamageOrZero
                Pair(chargeType, damage)
            }
            else -> null
        }

        if (chargeTypeAndDamage != null) {
            chargeTypes.add(chargeTypeAndDamage.first)
            chargeTypeAndDamage.second
        } else 0.0
    }

    val resultCharges = chargeTypes.distinct()
    return Pair(totalDamage, resultCharges)
}


/**
 * Computes the damage coefficient of the given smartbomb.
 */
private fun smartbombDamageCoef(smartbombAttrs: SmartbombAttrs, distance: Double): Double {
    val optimal = smartbombAttrs.optimalRange ?: return 0.0
    return if (distance < optimal) 1.0 else 0.0
}


/**
 * Computes the damage coefficient of the given Vorton Projector.
 */
private fun vortonProjectorDamageCoef(
    projectorAttrs: VortonProjectorAttrs,
    target: DamageTarget,
    targetVelocity: ShipRelativeVelocityVectorState,
    distance: Double
): Double {
    val optimal = projectorAttrs.optimalRange ?: return 0.0
    if (distance > optimal) return 0.0
    return missileDamageCoefficient(
        explosionRadius = projectorAttrs.explosionRadius ?: return 0.0,
        explosionVelocity = projectorAttrs.explosionVelocity ?: return 0.0,
        drf = projectorAttrs.damageReductionFactor ?: return 0.0,
        targetSpeed = targetVelocity.magnitude * target.maxVelocity,
        targetSignatureRadius = target.signatureRadius
    )
}


/**
 * Computes the damage coefficient of the given bomb launcher.
 */
private fun bombDamageCoef(bombAttrs: BombAttrs, target: DamageTarget, distance: Double): Double {
    val explosionRange = bombAttrs.explosionRange ?: return 0.0
    val range = bombAttrs.range ?: return 0.0
    val effectiveExplosionRange = explosionRange + target.radius
    val shortRangeMin = range.shortRange - effectiveExplosionRange
    val shortRangeMax = range.shortRange + effectiveExplosionRange
    val longRangeMin = range.longRange - effectiveExplosionRange
    val longRangeMax = range.longRange + effectiveExplosionRange

    if ((distance < shortRangeMin) || (distance > longRangeMax))
        return 0.0

    val coef = bombDamageCoefficient(
        explosionRadius = bombAttrs.explosionRadius ?: return 0.0,
        targetSignatureRadius = target.signatureRadius
    )
    val inShortRange = (distance >= shortRangeMin) && (distance <= shortRangeMax)
    val inLongRange = (distance >= longRangeMin) && (distance <= longRangeMax)
    return when {
        inShortRange && inLongRange -> coef
        inShortRange -> coef * range.shortProbability
        inLongRange -> coef * range.longProbability
        else -> 0.0
    }
}


/**
 * Computes the damage coefficient of the given turret or drone.
 */
private fun turretOrDroneDamageCoef(
    turretOrDrone: ModuleOrDrone<*>,
    angularVelocity: Double,
    centersDistance: Double,
    signatureRadius: Double,
    includeWreckingShots: Boolean
): Double {
    val trackingSpeed = turretOrDrone.trackingSpeed?.doubleValue
    val signatureResolution = turretOrDrone.signatureResolution?.doubleValue
    val optimal = turretOrDrone.optimalRange?.doubleValue
    val falloff = turretOrDrone.falloffRange?.doubleValue ?: 0.0

    if ((trackingSpeed == null) || (signatureResolution == null) || (optimal == null))
        return 0.0

    return turretDamageCoefficient(
        angularVelocity = angularVelocity,
        centersDistance = centersDistance,
        signatureRadius = signatureRadius,
        trackingSpeed = trackingSpeed,
        signatureResolution = signatureResolution,
        optimal = optimal,
        falloff = falloff,
        includeWreckingShots = includeWreckingShots
    )
}


/**
 * Computes the damage coefficient of the given turret or drone.
 */
private fun turretDamageCoef(
    turretParams: TurretAttrs,
    angularVelocity: Double,
    centersDistance: Double,
    signatureRadius: Double,
    includeWreckingShots: Boolean
): Double {
    val trackingSpeed = turretParams.trackingSpeed
    val signatureResolution = turretParams.signatureResolution
    val optimal = turretParams.optimalRange
    val falloff = turretParams.falloffRange ?: 0.0

    if ((trackingSpeed == null) || (signatureResolution == null) || (optimal == null))
        return 0.0

    return turretDamageCoefficient(
        angularVelocity = angularVelocity,
        centersDistance = centersDistance,
        signatureRadius = signatureRadius,
        trackingSpeed = trackingSpeed,
        signatureResolution = signatureResolution,
        optimal = optimal,
        falloff = falloff,
        includeWreckingShots = includeWreckingShots
    )
}


/**
 * Computes the damage coefficient of the given missile launcher.
 */
private fun missileLauncherDamageCoef(
    missileAttrs: MissileAttrs,
    target: DamageTarget,
    targetVelocity: ShipRelativeVelocityVectorState,
    distance: Double,
): Double {
    val range = missileAttrs.range ?: return 0.0
    if (distance > range.longRange)
        return 0.0

    val rangeCoef = if (distance < range.shortRange) 1.0 else range.longProbability

    val damageCoef = missileDamageCoefficient(
        explosionRadius = missileAttrs.explosionRadius ?: return 0.0,
        explosionVelocity = missileAttrs.explosionVelocity ?: return 0.0,
        drf = missileAttrs.damageReductionFactor ?: return 0.0,
        targetSpeed = targetVelocity.magnitude * target.maxVelocity,
        targetSignatureRadius = target.signatureRadius
    )

    return rangeCoef * damageCoef
}


/**
 * Computes the damage of the given list of drones.
 */
private fun droneDamage(
    drones: List<DroneGroup>,
    damageByDrone: Map<DroneGroup, Double?>,
    target: DamageTarget,
    targetVelocity: ShipRelativeVelocityVectorState,
    distance: Double,
    includeWreckingShots: Boolean
): Double {
    return drones.sumOf { drone ->
        val damage = damageByDrone[drone]
        if ((damage == null) || (damage == 0.0))
            return@sumOf 0.0
        with(TheorycrafterContext.eveData) {
            val mwdSpeed = drone.mwdSpeed?.value
            val coef = if (mwdSpeed != null) {
                mobileDroneDamageCoef(
                    drone = drone,
                    target = target,
                    targetVelocity = targetVelocity,
                )
            } else {
                sentryDroneDamageCoef(
                    drone = drone,
                    target = target,
                    targetVelocity = targetVelocity,
                    distance = distance,
                    includeWreckingShots = includeWreckingShots
                )
            }
            coef * damage
        }
    }
}


/**
 * Computes the damage coefficient of mobile drones.
 */
private fun mobileDroneDamageCoef(
    drone: DroneGroup,
    target: DamageTarget,
    targetVelocity: ShipRelativeVelocityVectorState,
): Double {
    // This is *very* simplistic, but barring something reasonably accurate, it's better to not confuse the user with
    // bad approximations.
    val mwdSpeed = drone.mwdSpeed?.value ?: return 0.0
    return if (mwdSpeed > targetVelocity.magnitude * target.maxVelocity)
        1.0
    else
        0.0
}


/**
 * Computes the damage coefficient of sentry drones.
 */
private fun sentryDroneDamageCoef(
    drone: DroneGroup,
    target: DamageTarget,
    targetVelocity: ShipRelativeVelocityVectorState,
    distance: Double,
    includeWreckingShots: Boolean
): Double {
    val centersDistance = distance + target.radius
    val angularVelocity = visualAngularVelocity(
        centersDistance = centersDistance,
        attackerRelativeVelocity = ShipRelativeVelocityVectorState(magnitude = 0.0, direction = 0.0),
        attackerMaxVelocity = 0.0,
        targetRelativeVelocity = targetVelocity,
        targetMaxVelocity = target.maxVelocity,
    )
    return turretOrDroneDamageCoef(
        turretOrDrone = drone,
        angularVelocity = angularVelocity,
        centersDistance = centersDistance,
        signatureRadius = target.signatureRadius,
        includeWreckingShots = includeWreckingShots
    )
}


/**
 * The width of the 2nd "column" in the damage graph params.
 */
private val SecondColumnGraphParamsWidth = 230.dp


/**
 * The size (height) of the ship velocity editor.
 */
private val ShipVelocityEditorSize = 120.dp


/**
 * The types of ammo to simulate loading into the weapons, to determine the damage.
 */
@Serializable
enum class AmmoSelection {


    @SerialName("loaded")
    CurrentlyLoaded,

    @SerialName("tech1")
    Tech1,

    @SerialName("tech1Or2")
    Tech1Or2,

    @SerialName("faction")
    EmpireFaction,

    @SerialName("factionOrTech2")
    EmpireFactionOrTech2,

    @SerialName("pirateOrTech2")
    PirateOrTech2;


    /**
     * Returns the list of charges to consider using for `this` ammo selection choice.
     */
    context(EveData)
    fun chargesFor(module: Module): List<ChargeType> = chargesForModule(module.type).filter {
        when (this) {
            CurrentlyLoaded -> it == module.loadedCharge?.type
            Tech1 -> it.isTech1Item
            Tech1Or2 -> it.isTech1Item || it.isTech2Item
            EmpireFaction -> it.isEmpireNavyFactionItem
            EmpireFactionOrTech2 -> it.isEmpireNavyFactionItem || it.isTech2Item
            PirateOrTech2 -> it.isElitePirateFactionItem || it.isTech2Item
        }
    }


}


/**
 * The UI for the damage graph parameters.
 */
@Composable
private fun DamageGraphParams(
    attackers: MutableList<GraphFit>,
    targets: MutableList<DamageTarget>,
    attackersVelocity: ShipRelativeVelocityVectorState,
    targetsVelocity: ShipRelativeVelocityVectorState,
    showVolley: MutableState<Boolean>,
    includeDrones: MutableState<Boolean>,
    includeWreckingShots: MutableState<Boolean>,
    ammoSelection: MutableState<AmmoSelection>,
    virtualTargetSignatureRadius: MutableState<Double>,
    virtualTargetMaxVelocity: MutableState<Double>,
    modifier: Modifier,
) {
    Column(
        modifier = modifier,
        verticalArrangement = Arrangement.spacedBy(TheorycrafterTheme.spacing.xxxlarge)
    ) {
        Row(
            horizontalArrangement = Arrangement.spacedBy(
                space = TheorycrafterTheme.spacing.larger,
                alignment = Alignment.Start,
            ),
            modifier = Modifier
                .height(IntrinsicSize.Max)
                .fillMaxWidth()
        ) {
            val nextAttackerColor = nextGraphSourceColor(attackers)
            GraphSourceList(
                title = "Attackers",
                sources = attackers,
                mutableSources = attackers,
                infoColumnWidths = emptyList(),
                infoCells = { _, _ -> },
                nextColor = nextAttackerColor.takeIf { attackers.isEmpty() || (targets.size <= 1) },
                modifier = Modifier
                    .fillMaxHeight()
                    .weight(1f)
            )

            Column(
                modifier = Modifier
                    .width(SecondColumnGraphParamsWidth)
                    .align(Alignment.Bottom),
            ) {
                SingleLineText(text = "Show:")
                VSpacer(TheorycrafterTheme.spacing.xxsmall)
                RadioButtonWithText(
                    text = "Damage per second",
                    selected = !showVolley.value,
                    onClick = { showVolley.value = false },
                    modifier = Modifier.fillMaxWidth()
                )
                RadioButtonWithText(
                    text = "Volley (alpha) damage",
                    selected = showVolley.value,
                    onClick = { showVolley.value = true },
                    modifier = Modifier.fillMaxWidth()
                )

                VSpacer(TheorycrafterTheme.spacing.small)

                CheckboxedText(
                    text = "Include drones",
                    state = includeDrones,
                    modifier = Modifier.fillMaxWidth()
                )
                CheckboxedText(
                    text = "Include wrecking shots",
                    state = includeWreckingShots,
                    modifier = Modifier.fillMaxWidth()
                )

                VSpacer(TheorycrafterTheme.spacing.small)

                TextDropdownField(
                    label = "Ammo selection",
                    items = AmmoSelection.entries,
                    selectedItem = ammoSelection.value,
                    onItemSelected = { _, value -> ammoSelection.value = value },
                    itemToString = {
                        when (it) {
                            AmmoSelection.CurrentlyLoaded -> "Currently Loaded"
                            AmmoSelection.Tech1 -> "Tech 1"
                            AmmoSelection.Tech1Or2 -> "Tech 1 or Tech 2"
                            AmmoSelection.EmpireFaction -> "Empire Faction"
                            AmmoSelection.EmpireFactionOrTech2 -> "Empire Faction or Tech 2"
                            AmmoSelection.PirateOrTech2 -> "Pirate or Tech 2"
                        }
                    },
                )
            }

            HSpacer(TheorycrafterTheme.spacing.xxlarge)

            ShipVelocityEditor(
                title = "Attacker",
                vector = attackersVelocity,
            )
        }
        Row(
            horizontalArrangement = Arrangement.spacedBy(
                space = TheorycrafterTheme.spacing.larger,
                alignment = Alignment.Start,
            ),
        ) {
            val nextTargetColor = nextGraphSourceColor(targets)
            GraphSourceList(
                title = "Targets",
                sources = targets,
                mutableSources = targets,
                infoColumnWidths = listOf(70.dp, 70.dp),
                infoCells = { source, firstInfoColumnIndex ->
                    DamageTargetRowInfoCells(source as DamageTarget, firstInfoColumnIndex)
                },
                nextColor = nextTargetColor.takeIf { targets.isEmpty() || (attackers.size <= 1) },
                modifier = Modifier
                    .weight(1f)
            )

            AddVirtualDamageTarget(
                targets = targets,
                virtualTargetSignatureRadius = virtualTargetSignatureRadius,
                virtualTargetMaxVelocity = virtualTargetMaxVelocity,
                nextTargetColor = nextTargetColor,
                modifier = Modifier.width(SecondColumnGraphParamsWidth),
            )

            HSpacer(TheorycrafterTheme.spacing.xxlarge)

            ShipVelocityEditor(
                title = "Target",
                vector = targetsVelocity,
            )
        }
    }
}


/**
 * The cells of the info columns of the damage target row, when not edited.
 */
@Composable
private fun GridScope.GridRowScope.DamageTargetRowInfoCells(
    source: DamageTarget,
    firstColumnIndex: Int
) {
    cell(firstColumnIndex, contentAlignment = Alignment.CenterStart) {
        SingleLineText(
            text = source.maxVelocity.asSpeed(withUnits = true),
            modifier = Modifier.tooltip("Max. velocity")
        )
    }
    cell(firstColumnIndex+1, contentAlignment = Alignment.CenterStart) {
        SingleLineText(
            text = source.signatureRadius.let {
                if (it == Double.POSITIVE_INFINITY)
                    "Infinite"
                else
                    it.asSignatureRadius(withUnits = true)
            },
            modifier = Modifier.tooltip("Signature Radius")
        )
    }
}


/**
 * The UI for adding a virtual [DamageTarget] with specific, user-provided properties.
 */
@Composable
private fun AddVirtualDamageTarget(
    targets: MutableList<DamageTarget>,
    virtualTargetSignatureRadius: MutableState<Double>,
    virtualTargetMaxVelocity: MutableState<Double>,
    nextTargetColor: Color?,
    modifier: Modifier
) {
    Column(
        modifier = modifier
            .height(  // Needed due to https://github.com/JetBrains/compose-multiplatform/issues/4760
                if (hostOs == OS.Windows) 166.dp else 160.dp
            )
    ) {
        Text("Virtual Target", style = TheorycrafterTheme.textStyles.mediumHeading)
        VSpacer(TheorycrafterTheme.spacing.small)

        var signatureRadius: Double? by rememberSaveable { mutableStateOf(virtualTargetSignatureRadius.value) }
        DoubleTextField(
            value = signatureRadius,
            onValueChange = { signatureRadius = it },
            formatter = maxPrecisionFormatter(precision = 1),
            constraint = { it > 0 },
            label = { Text("Signature Radius") },
            visualTransformation = rememberAppendSuffixTransformation { "${NNBSP}m" },
            modifier = Modifier
                .fillMaxWidth()
        )

        VSpacer(TheorycrafterTheme.spacing.small)

        var maxVelocity: Double? by rememberSaveable { mutableStateOf(virtualTargetMaxVelocity.value) }
        DoubleTextField(
            value = maxVelocity,
            onValueChange = { maxVelocity = it },
            formatter = maxPrecisionFormatter(precision = 1),
            constraint = { it > 0 },
            label = { Text("Max. Velocity") },
            visualTransformation = rememberAppendSuffixTransformation { "${NNBSP}m/s" },
            modifier = Modifier
                .fillMaxWidth()
        )

        Spacer(Modifier.height(TheorycrafterTheme.spacing.medium).weight(1f))

        Row(
            modifier = Modifier
                .fillMaxWidth(),
            horizontalArrangement = Arrangement.SpaceBetween
        ) {
            val buttonContentPadding = PaddingValues(TheorycrafterTheme.spacing.xsmall, TheorycrafterTheme.spacing.xxsmall)
            val buttonTextStyle = LocalTextStyle.current.let {
                it.copy(fontSize = 0.9*it.fontSize, letterSpacing = if (hostOs == OS.Windows) it.letterSpacing else -(0.1).sp)
            }
            RaisedButtonWithText(
                text = "Add Virtual Target",
                enabled = (nextTargetColor != null) && (signatureRadius != null) && (maxVelocity != null),
                style = buttonTextStyle,
                contentPadding = buttonContentPadding,
                onClick = {
                    val definiteSigRadius = signatureRadius!!
                    val definiteMaxVelocity = maxVelocity!!
                    targets.add(
                        DamageTarget.Virtual(
                            color = nextTargetColor!!,
                            signatureRadius = definiteSigRadius,
                            maxVelocity = definiteMaxVelocity
                        )
                    )
                    virtualTargetSignatureRadius.value = definiteSigRadius
                    virtualTargetMaxVelocity.value = definiteMaxVelocity
                },
                modifier = Modifier
                    .then(
                        when {
                            nextTargetColor == null -> Modifier.tooltip("Max. targets reached")
                            signatureRadius == null -> Modifier.tooltip("Illegal signature radius value")
                            maxVelocity == null -> Modifier.tooltip("Illegal max. velocity value")
                            else -> Modifier
                        }
                    )
            )
            RaisedButtonWithText(
                text = "Add Ideal Target",
                enabled = (nextTargetColor != null),
                style = buttonTextStyle,
                contentPadding = buttonContentPadding,
                onClick = { targets.add(DamageTarget.Ideal(nextTargetColor!!)) },
                modifier = Modifier
                    .then(
                        when {
                            nextTargetColor == null -> Modifier.tooltip("Max. targets reached")
                            else -> Modifier
                        }
                    )
            )
        }
    }
}


/**
 * The state object for the ship's relative (to its maximum) velocity vector.
 */
@Stable
private class ShipRelativeVelocityVectorState(
    magnitude: Double = 0.0,
    direction: Double = 0.0
) {


    /**
     * The magnitude of the vector; a value between 0 and 1.
     */
    var magnitude by mutableDoubleStateOf(magnitude)


    /**
     * The angle the vector; a value between -PI and +PI.
     */
    var direction by mutableStateOf(direction)


    /**
     * The x component of the vector.
     */
    val x: Double
        get() = magnitude * cos(direction)


    /**
     * The y component of the vector.
     */
    val y: Double
        get() = magnitude * sin(direction)


}


/**
 * The amount [ShipRelativeVelocityVectorState.direction] is snapped when the user holds down the Shift key.
 */
private const val DegreesSnap = 15.0


/**
 * The amount [ShipRelativeVelocityVectorState.magnitude] is snapped when the user holds down the Shift key.
 */
private const val MagnitudeSnap = 0.05


/**
 * A widget for displaying and editing a [ShipRelativeVelocityVectorState].
 */
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun ShipVelocityEditor(
    title: String,
    vector: ShipRelativeVelocityVectorState,
    modifier: Modifier = Modifier,
    tooltipPlacement: EasyTooltipPlacement = EasyTooltipPlacement.ElementCenterStart(DpOffsetX(-(6).dp)),
) {
    Column(modifier) {
        Text(
            text = title,
            modifier = Modifier.width(ShipVelocityEditorSize),
            textAlign = TextAlign.Center,
            style = TheorycrafterTheme.textStyles.mediumHeading
        )
        VSpacer(TheorycrafterTheme.spacing.small)

        Row(
            horizontalArrangement = Arrangement.spacedBy(TheorycrafterTheme.spacing.small),
        ) {
            val backgroundColor = TheorycrafterTheme.colors.interactiveBackground()
            val arrowColor = LocalContentColor.current
            val windowInfo = LocalWindowInfo.current
            Box(
                Modifier
                    .size(ShipVelocityEditorSize)
                    .tooltip("""
                        Click or drag to change vector.
                        Use mouse-wheel to adjust magnitude only. 
                        Hold shift to make large, round changes.
                        Hold alt to disable snap-to-zero.
                    """.trimIndent(),
                        placement = tooltipPlacement,
                        delayMillis = 1_000
                    )
                    .drawWithCache {
                        val backgroundBrush = SolidColor(backgroundColor)
                        val arrowBrush = SolidColor(arrowColor)
                        val arrowStrokeWidth = 2.dp.roundToPx()
                        val arrowHeadSize = 8.dp.roundToPx().toFloat()
                        val arrowHeadPath = Path().apply {
                            moveTo(0f, -arrowHeadSize/2)
                            lineTo(arrowHeadSize, 0f)
                            lineTo(0f, arrowHeadSize/2)
                            lineTo(arrowHeadSize/5f, 0f)
                        }
                        onDrawBehind {
                            drawCircle(backgroundBrush)
                            val arrowHeadOffset = center + Offset(
                                x = (vector.x * (size.width/2 - arrowHeadSize)).toFloat(),
                                y = (vector.y * (size.height/2 - arrowHeadSize)).toFloat()
                            )
                            drawLine(
                                brush = arrowBrush,
                                start = center,
                                end = arrowHeadOffset,
                                strokeWidth = arrowStrokeWidth.toFloat(),
                                cap = StrokeCap.Round
                            )
                            // Draw the arrow head
                            translate(left = arrowHeadOffset.x, top = arrowHeadOffset.y) {
                                rotateRad(radians = vector.direction.toFloat(), pivot = Offset.Zero) {
                                    if (vector.magnitude == 0.0) {
                                        drawCircle(
                                            brush = arrowBrush,
                                            radius = arrowHeadSize/2f,
                                            center = Offset.Zero
                                        )
                                    } else {
                                        drawPath(
                                            path = arrowHeadPath,
                                            brush = arrowBrush,
                                        )
                                    }
                                }
                            }
                        }
                    }
                    .pointerInput(vector) {
                        val center = Offset(x = size.width/2f, y = size.height/2f)
                        fun updateArrowPosition(position: Offset) {
                            val dx = (position.x - center.x).toDouble()
                            val dy = (position.y - center.y).toDouble()
                            val x = dx / center.x
                            val y = dy / center.y
                            val isShiftPressed = windowInfo.keyboardModifiers.isShiftPressed
                            val isAltPressed = windowInfo.keyboardModifiers.isAltPressed
                            vector.magnitude = sqrt(x*x + y*y).coerceAtMost(1.0).let {
                                if (!isAltPressed && (it <= 0.03))
                                    0.0
                                else if (isShiftPressed)
                                    it.roundToMultipleOf(MagnitudeSnap)
                                else it
                            }
                            vector.direction = atan2(y, x).let {
                                when {
                                    vector.magnitude == 0.0 -> 0.0
                                    isShiftPressed -> {
                                        it.rad2deg().roundToMultipleOf(DegreesSnap).deg2rad()
                                    }
                                    else -> it
                                }
                            }
                        }
                        awaitEachGesture {
                            val down = awaitFirstDown()
                            updateArrowPosition(down.position)
                            while (true) {
                                val drag = awaitDragOrCancellation(down.id)
                                if (drag == null)
                                    break
                                updateArrowPosition(drag.position)
                            }
                        }
                    }
                    .onPointerEvent(eventType = PointerEventType.Scroll) { event ->
                        val isShiftPressed = windowInfo.keyboardModifiers.isShiftPressed
                        val delta = event.changes
                            .sumOf {
                                if (isShiftPressed) {
                                    // Shift-scroll is horizontal scroll, so use x value.
                                    // When shift-scrolling, the magnitude of values is different, so we simply count
                                    // the number of events.
                                    -it.scrollDelta.x.sign / 50.0
                                }
                                else {
                                    // Experimentally produces decent values on macOS with a mouse; could be bad with a
                                    // trackpad, or on other platforms.
                                    -it.scrollDelta.y / 5.0
                                }
                            }
                            .let {
                                it.sign * it.absoluteValue.coerceAtLeast(if (isShiftPressed) MagnitudeSnap else 0.01)
                            }
                        vector.magnitude = (vector.magnitude + delta)
                            .let {
                                if (isShiftPressed) it.roundToMultipleOf(MagnitudeSnap) else it
                            }
                            .coerceIn(0.0, 1.0)
                    }
            )

            Column(
                modifier = Modifier
                    .align(Alignment.CenterVertically)
                    .width(130.dp)
            ) {
                SingleLineText("Velocity: ${vector.magnitude.fractionAsPercentage(precision = 0)}")
                SingleLineText("Direction: ${(-vector.direction.rad2deg()).roundToInt()}°")
            }
        }
    }
}


/**
 * Rounds the value to the nearest given multiple.
 */
private fun Double.roundToMultipleOf(x: Double) = (this / x).roundToInt() * x
