Significantly improve chart clarity in tooltips

This commit is contained in:
DBotThePony 2024-11-15 13:41:16 +07:00
parent 46d04a4217
commit 7664b0b4de
Signed by: DBot
GPG Key ID: DCC23B5715498507
7 changed files with 368 additions and 58 deletions

View File

@ -21,6 +21,7 @@ import org.lwjgl.opengl.GL11.GL_LESS
import ru.dbotthepony.kommons.math.RGBAColor import ru.dbotthepony.kommons.math.RGBAColor
import ru.dbotthepony.mc.otm.client.minecraft import ru.dbotthepony.mc.otm.client.minecraft
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.absoluteValue
import kotlin.math.acos import kotlin.math.acos
import kotlin.math.cos import kotlin.math.cos
import kotlin.math.pow import kotlin.math.pow
@ -287,10 +288,31 @@ fun renderChart(
val font = levelLabels.font ?: minecraft.font val font = levelLabels.font ?: minecraft.font
for ((level, label) in levelLabels.labels) { for ((level, label) in levelLabels.labels) {
var conflictingTop = false
var conflictingBottom = false
levelLabels.labels.keys.forEach {
if (it != level) {
conflictingTop = conflictingTop || it > level && (it - level).absoluteValue * height < font.lineHeight.toFloat()
conflictingBottom = conflictingBottom || it < level && (it - level).absoluteValue * height < font.lineHeight.toFloat()
}
}
val y0 = y + (1f - level) * height val y0 = y + (1f - level) * height
val tX = x + (levelLabels.textGravity.repositionX(width - 1f, font.width(label).toFloat() * textScale - 1f)) val tX = x + (levelLabels.textGravity.repositionX(width - 1f, font.width(label).toFloat() * textScale - 1f))
val tY = y0 - lineWidth - 1f - font.lineHeight * textScale + levelLabels.textGravity.repositionY(font.lineHeight * 2f * textScale + lineWidth + 2f, font.lineHeight.toFloat() * textScale) + 1f var tY = y0 + levelLabels.textGravity.repositionY(font.lineHeight * 2f * textScale + lineWidth + 2f, font.lineHeight.toFloat() * textScale)
if (conflictingTop && conflictingBottom) {
// do nothing, draw at center
tY -= 2f + lineWidth
} else if (conflictingTop) {
// draw at bottom
tY += lineWidth + 2f
} else {
// draw at top
tY -= font.lineHeight * textScale + lineWidth
}
font.draw( font.draw(
poseStack, poseStack,

View File

@ -50,7 +50,6 @@ object ClientConfig : AbstractConfig("client", ModConfig.Type.CLIENT) {
var CHARTS_IN_TOOLTIPS: Boolean by builder var CHARTS_IN_TOOLTIPS: Boolean by builder
.comment("Draw charts in storage tooltips instead of list of last 20 values.") .comment("Draw charts in storage tooltips instead of list of last 20 values.")
.comment("Disable to get EnderIO-like experience")
.define("CHARTS_IN_TOOLTIPS", true) .define("CHARTS_IN_TOOLTIPS", true)
init { init {

View File

@ -1,10 +1,8 @@
package ru.dbotthepony.mc.otm.core.chart package ru.dbotthepony.mc.otm.core.chart
import com.google.common.collect.ImmutableList
import com.mojang.serialization.Codec import com.mojang.serialization.Codec
import net.minecraft.network.RegistryFriendlyByteBuf import net.minecraft.network.RegistryFriendlyByteBuf
import ru.dbotthepony.mc.otm.core.collect.reduce import ru.dbotthepony.mc.otm.core.collect.reduce
import ru.dbotthepony.mc.otm.core.immutableList
import ru.dbotthepony.mc.otm.core.math.Decimal import ru.dbotthepony.mc.otm.core.math.Decimal
import ru.dbotthepony.mc.otm.data.DecimalCodec import ru.dbotthepony.mc.otm.data.DecimalCodec
import ru.dbotthepony.mc.otm.network.MatteryStreamCodec import ru.dbotthepony.mc.otm.network.MatteryStreamCodec
@ -33,28 +31,4 @@ class DecimalHistoryChart : AbstractHistoryChart<Decimal> {
get() = DecimalCodec get() = DecimalCodec
override val streamCodec: MatteryStreamCodec<RegistryFriendlyByteBuf, Decimal> override val streamCodec: MatteryStreamCodec<RegistryFriendlyByteBuf, Decimal>
get() = DecimalCodec.NETWORK get() = DecimalCodec.NETWORK
companion object {
private val HISTORY_WEIGHTERS: ImmutableList<Decimal> = immutableList {
/*for (i in 0 until 20) {
accept(Decimal(0.5.pow(i + 1)))
}*/
val average = Decimal(1) / Decimal(20)
for (i in 0 until 20) {
accept(average)
}
}
fun calcWeightedAverage(getter: (Int) -> Decimal): Decimal {
var result = Decimal.ZERO
for (i in 0 until 20) {
result += getter(i) * HISTORY_WEIGHTERS[i]
}
return result
}
}
} }

View File

@ -1,5 +1,8 @@
package ru.dbotthepony.mc.otm.core.chart package ru.dbotthepony.mc.otm.core.chart
import ru.dbotthepony.mc.otm.core.math.Cluster
import java.util.random.RandomGenerator
/** /**
* Common interface for reading chart values * Common interface for reading chart values
*/ */

View File

@ -0,0 +1,175 @@
package ru.dbotthepony.mc.otm.core.math
import ru.dbotthepony.mc.otm.core.collect.filter
import ru.dbotthepony.mc.otm.core.collect.map
import ru.dbotthepony.mc.otm.core.collect.toList
import ru.dbotthepony.mc.otm.core.random
import java.util.random.RandomGenerator
interface Cluster<V : Any> {
val values: List<V>
val center: V
data class Impl<V : Any>(override val values: List<V>, override val center: V) : Cluster<V>
}
private class ClusterValue<V : Comparable<V>>(val value: V, var cluster: MutableCluster<V>, var error: V) {
inline fun updateError(abs: (V) -> V, minus: (V, V) -> V) {
error = abs(minus(cluster.center, value))
}
inline fun maybeSwitchCluster(cluster: MutableCluster<V>, abs: (V) -> V, minus: (V, V) -> V): Boolean {
if (cluster == this.cluster) return false
val newError = abs(minus(cluster.center, value))
if (newError < error) {
error = newError
this.cluster.values.remove(this)
cluster.values.add(this)
this.cluster = cluster
return true
} else {
return false
}
}
}
private class MutableCluster<V : Comparable<V>>(var center: V) {
val values = ArrayList<ClusterValue<V>>()
inline fun calculateCenter(identity: V, plus: (V, V) -> V, minus: (V, V) -> V, divInt: (V, Int) -> V, abs: (V) -> V): Boolean {
if (values.isEmpty()) return false
var value = identity
values.forEach { value = plus(value, it.value) }
val old = center
center = divInt(value, values.size)
if (old != center) {
values.forEach { it.updateError(abs, minus) }
}
return true
}
}
private inline fun <V : Comparable<V>> Iterable<V>.clusterize(
random: RandomGenerator,
initialClusters: Int = 1,
identity: V,
plus: (V, V) -> V,
minus: (V, V) -> V,
divInt: (V, Int) -> V,
abs: (V) -> V,
heuristics: (min: V, max: V, error: V) -> Boolean,
): List<Cluster<V>> {
val itr = iterator()
if (!itr.hasNext())
return listOf()
var min = itr.next()
var max = min
while (itr.hasNext()) {
val value = itr.next()
min = minOf(min, value)
max = maxOf(max, value)
}
if (min == max) {
return listOf(Cluster.Impl(listOf(min), min))
}
var targetClusters = initialClusters
while (true) {
val clusters = ArrayList<MutableCluster<V>>()
val values = ArrayList<ClusterValue<V>>()
var converged = false
var overSaturated = false
for (i in 0 until targetClusters) {
clusters.add(MutableCluster(identity))
}
for (value in this) {
val cluster = clusters.random(random)
val wrapped = ClusterValue(value, cluster, value)
values.add(wrapped)
cluster.values.add(wrapped)
}
clusters.forEach { it.calculateCenter(identity, plus, minus, divInt, abs) }
while (!converged) {
converged = true
for (value in values) {
clusters.forEach { converged = !value.maybeSwitchCluster(it, abs, minus) && converged }
}
if (!converged) {
val emptyClusters = ArrayList<MutableCluster<V>>()
val citr = clusters.iterator()
for (cluster in citr) {
if (!cluster.calculateCenter(identity, plus, minus, divInt, abs)) {
emptyClusters.add(cluster)
citr.remove()
}
}
for (cluster in emptyClusters) {
var candidate: ClusterValue<V>? = null
for (eCluser in clusters) {
if (eCluser.values.size > 1) {
for (value in eCluser.values) {
if (candidate == null || candidate.error < value.error) {
candidate = value
}
}
}
}
if (candidate != null) {
cluster.values.add(candidate)
candidate.cluster.values.remove(candidate)
candidate.cluster = cluster
cluster.center = candidate.value
candidate.error = identity
clusters.add(cluster)
} else {
overSaturated = true
break
}
}
}
}
val maxError = values.maxOf { it.error }
if (!overSaturated && targetClusters < values.size && heuristics(min, max, maxError)) {
targetClusters++
} else {
return clusters.iterator()
.filter { it.values.isNotEmpty() }
.map { Cluster.Impl(it.values.map { it.value }, it.center) }
.toList()
}
}
}
private val DECIMAL_ERROR_TOLERANCE = Decimal("0.04")
fun Iterable<Decimal>.clusterize(random: RandomGenerator, clusters: Int? = null): List<Cluster<Decimal>> {
return clusterize(random, clusters ?: 1, Decimal.ZERO, Decimal::plus, Decimal::minus, Decimal::div, Decimal::absoluteValue) { min, max, error ->
if (clusters != null)
false
else
error / (max - min) >= DECIMAL_ERROR_TOLERANCE
}
}

View File

@ -15,10 +15,10 @@ import ru.dbotthepony.mc.otm.config.ClientConfig
import ru.dbotthepony.mc.otm.core.TextComponent import ru.dbotthepony.mc.otm.core.TextComponent
import ru.dbotthepony.mc.otm.core.TranslatableComponent import ru.dbotthepony.mc.otm.core.TranslatableComponent
import ru.dbotthepony.mc.otm.core.math.Decimal import ru.dbotthepony.mc.otm.core.math.Decimal
import ru.dbotthepony.mc.otm.core.math.clusterize
import ru.dbotthepony.mc.otm.core.math.isNegative import ru.dbotthepony.mc.otm.core.math.isNegative
import ru.dbotthepony.mc.otm.core.math.isZero import ru.dbotthepony.mc.otm.core.math.isZero
import ru.dbotthepony.mc.otm.menu.widget.IProfiledLevelGaugeWidget import ru.dbotthepony.mc.otm.menu.widget.IProfiledLevelGaugeWidget
import ru.dbotthepony.mc.otm.menu.widget.ProfiledLevelGaugeWidget
import java.math.BigInteger import java.math.BigInteger
import java.util.function.BooleanSupplier import java.util.function.BooleanSupplier
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
@ -314,34 +314,34 @@ private fun formatHistoryChart(
if (widget.received[it].isInfinite && widget.transferred[it].isInfinite) if (widget.received[it].isInfinite && widget.transferred[it].isInfinite)
0.5f 0.5f
else if (diff[it].isInfinite && diff[it].isNegative) else if (diff[it].isInfinite && diff[it].isNegative)
0.1f 0f
else if (diff[it].isInfinite && diff[it].isPositive) else if (diff[it].isInfinite && diff[it].isPositive)
0.9f 1f
else else
0.5f 0.5f
} }
received = FloatArray(diff.size) { received = FloatArray(diff.size) {
if (widget.received[it].isInfinite) 0.9f else 0.5f if (widget.received[it].isInfinite) 1f else 0.5f
} }
transferred = FloatArray(diff.size) { transferred = FloatArray(diff.size) {
if (widget.transferred[it].isInfinite) 0.1f else 0.5f if (widget.transferred[it].isInfinite) 0f else 0.5f
} }
labels = ChartLevelLabels( labels = ChartLevelLabels(
labels = mapOf( labels = mapOf(
0.5f to Decimal.ZERO.formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias), 0.5f to Decimal.ZERO.formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias),
0.1f to TextComponent("-∞"), 0f to TextComponent("-∞"),
0.9f to TextComponent(""), 1f to TextComponent(""),
) )
) )
} else if (hasPositiveInfinity) { } else if (hasPositiveInfinity) {
normalizedDiff = FloatArray(diff.size) { normalizedDiff = FloatArray(diff.size) {
if (diff[it].isInfinite) if (diff[it].isInfinite)
0.9f 1f
else else
0.5f 0f
} }
received = FloatArray(diff.size) { received = FloatArray(diff.size) {
@ -353,16 +353,16 @@ private fun formatHistoryChart(
labels = ChartLevelLabels( labels = ChartLevelLabels(
labels = mapOf( labels = mapOf(
0.5f to Decimal.ZERO.formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias), 0f to Decimal.ZERO.formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias),
0.9f to TextComponent(""), 1f to TextComponent(""),
) )
) )
} else if (hasNegativeInfinity) { } else if (hasNegativeInfinity) {
normalizedDiff = FloatArray(diff.size) { normalizedDiff = FloatArray(diff.size) {
if (diff[it].isInfinite) if (diff[it].isInfinite)
0.1f 0f
else else
0.5f 1f
} }
received = FloatArray(diff.size) received = FloatArray(diff.size)
@ -374,17 +374,16 @@ private fun formatHistoryChart(
labels = ChartLevelLabels( labels = ChartLevelLabels(
labels = mapOf( labels = mapOf(
0.5f to Decimal.ZERO.formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias), 1f to Decimal.ZERO.formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias),
0.1f to TextComponent("-∞"), 0f to TextComponent("-∞"),
) )
) )
} else { } else {
val max = maxOf(widget.received.maxOrNull() ?: Decimal.ZERO, widget.transferred.maxOrNull() ?: Decimal.ZERO) val maxTransferred = widget.transferred.maxOrNull() ?: Decimal.ZERO
val maxReceived = widget.received.maxOrNull() ?: Decimal.ZERO
val labelNames = Float2ObjectArrayMap<Component>() val labelNames = Float2ObjectArrayMap<Component>()
labelNames[0.5f] = Decimal.ZERO.formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias) if (maxTransferred.isZero && maxReceived.isZero) {
if (max.isZero) {
normalizedDiff = FloatArray(diff.size) normalizedDiff = FloatArray(diff.size)
normalizedDiff.fill(0.5f) normalizedDiff.fill(0.5f)
@ -394,25 +393,131 @@ private fun formatHistoryChart(
transferred = FloatArray(diff.size) transferred = FloatArray(diff.size)
transferred.fill(0.5f) transferred.fill(0.5f)
labelNames[0.1f] = TextComponent("-∞") labelNames[0f] = TextComponent("-∞")
labelNames[0.9f] = TextComponent("") labelNames[0.5f] = Decimal.ZERO.formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias)
labelNames[1f] = TextComponent("")
} else { } else {
val zero: Float
val transferredMult: Float
val receivedMult: Float
if (maxTransferred == maxReceived) {
zero = 0.5f
transferredMult = 0.5f
receivedMult = 0.5f
} else if (maxTransferred > maxReceived) {
val ratio = (maxReceived / maxTransferred).toFloat()
receivedMult = ratio * 0.5f
zero = 1f - receivedMult
transferredMult = 1f - receivedMult
} else {
val ratio = (maxTransferred / maxReceived).toFloat()
transferredMult = ratio * 0.5f
zero = transferredMult
receivedMult = 1f - transferredMult
}
normalizedDiff = FloatArray(diff.size) { normalizedDiff = FloatArray(diff.size) {
(diff[it] / max).toFloat() * 0.4f + 0.5f if (diff[it].isZero) {
zero
} else if (diff[it].isPositive) {
zero + (diff[it] / maxReceived).toFloat() * receivedMult
} else {
zero + (diff[it] / maxTransferred).toFloat() * transferredMult
}
} }
if (maxReceived.isNotZero) {
received = FloatArray(diff.size) { received = FloatArray(diff.size) {
(widget.received[it] / max).toFloat() * 0.4f + 0.5f zero + (widget.received[it] / maxReceived).toFloat() * receivedMult
}
} else {
received = FloatArray(diff.size)
received.fill(1f)
} }
if (maxTransferred.isNotZero) {
transferred = FloatArray(diff.size) { transferred = FloatArray(diff.size) {
(widget.transferred[it] / max).toFloat() * -0.4f + 0.5f zero + (widget.transferred[it] / maxTransferred).toFloat() * -transferredMult
}
} else {
transferred = FloatArray(diff.size)
transferred.fill(0f)
} }
labelNames[0.1f] = (-max).formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias) if (maxTransferred.isNotZero && maxReceived.isNotZero)
if (verbose.asBoolean) labelNames[0.3f] = (-max * Decimal.ONE_HALF).formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias) labelNames[zero] = Decimal.ZERO.formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias)
labelNames[0.9f] = max.formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias)
if (verbose.asBoolean) labelNames[0.7f] = (max * Decimal.ONE_HALF).formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias) labelNames[0f] = (-maxTransferred).formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias)
labelNames[1f] = maxReceived.formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias)
val rand = java.util.Random()
if (maxTransferred.isNotZero && transferredMult > 0.2f) {
for (cluster in widget.transferred.clusterize(rand)) {
val perc = (cluster.center / maxTransferred).toFloat() * transferredMult
if (labelNames.keys.none { (it - perc).absoluteValue < 0.08f })
labelNames[perc] = (-cluster.center).formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias)
}
}
if (maxReceived.isNotZero && receivedMult > 0.2f) {
for (cluster in widget.received.clusterize(rand)) {
val perc = zero + (cluster.center / maxReceived).toFloat() * receivedMult
if (labelNames.keys.none { (it - perc).absoluteValue < 0.08f })
labelNames[perc] = cluster.center.formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias)
}
}
val clusters = diff.asIterable().clusterize(rand)
for (cluster in clusters) {
val perc: Float
if (cluster.center.isZero)
continue
else if (cluster.center.isPositive)
perc = zero + (cluster.center / maxReceived).toFloat() * receivedMult
else
perc = (1f + (cluster.center / maxTransferred).toFloat()) * transferredMult
if (labelNames.keys.none { (it - perc).absoluteValue < 0.08f })
labelNames[perc] = cluster.center.formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias)
}
/*
if ((zero - 0.5f).absoluteValue >= 0.25f) {
// если "ноль" далёк от центра графика, то надо попробовать добавить пограничную направляющую
if (maxReceived > maxTransferred) {
// пограничная на приём
val min = widget.received.minOrNull() ?: Decimal.ZERO
val perc = (min / maxReceived).toFloat()
if (min.isNotZero && perc < 0.92f) {
labelNames[zero + perc * receivedMult] = min.formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias)
}
} else if (maxTransferred.isNotZero) {
// пограничная на отдачу
val min = widget.transferred.minOrNull() ?: Decimal.ZERO
val perc = (min / maxTransferred).toFloat()
if (min.isNotZero && perc < 0.92f) {
labelNames[perc * transferredMult] = (-min).formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias)
}
}
} else {
// если нет, то надо попробовать добавить половинчатые направляющие
if (transferredMult > 0.3f && (maxReceived.isZero || verbose.asBoolean && maxTransferred.isNotZero))
labelNames[transferredMult * 0.5f] = (-maxTransferred * Decimal.ONE_HALF).formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias)
if (receivedMult > 0.3f && (maxTransferred.isZero || verbose.asBoolean && maxReceived.isNotZero))
labelNames[zero + receivedMult * 0.5f] = (maxReceived * Decimal.ONE_HALF).formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias)
}
*/
} }
labels = ChartLevelLabels(labels = labelNames) labels = ChartLevelLabels(labels = labelNames)
@ -442,6 +547,38 @@ private fun formatHistoryChart(
) )
} }
result.add(Either.left(TextComponent("")))
run {
val incoming = widget.received[0]
val outgoing = widget.transferred[0]
val delta = incoming - outgoing
val deltaColor = if (delta.isZero) ChatFormatting.GRAY else if (delta.isPositive) ChatFormatting.DARK_GREEN else ChatFormatting.DARK_RED
result.add(Either.left(TranslatableComponent(
"otm.gui.diff",
delta.formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias).copy().withStyle(deltaColor),
incoming.formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias).copy().withStyle(ChatFormatting.DARK_GREEN),
outgoing.formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias).copy().withStyle(ChatFormatting.DARK_RED),
)))
}
run {
val incoming = widget.received.calculateAverage()
val outgoing = widget.transferred.calculateAverage()
val delta = incoming - outgoing
val deltaColor = if (delta.isZero) ChatFormatting.GRAY else if (delta.isPositive) ChatFormatting.DARK_GREEN else ChatFormatting.DARK_RED
result.add(Either.left(TranslatableComponent(
"otm.gui.diff",
delta.formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias).copy().withStyle(deltaColor),
incoming.formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias).copy().withStyle(ChatFormatting.DARK_GREEN),
outgoing.formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias).copy().withStyle(ChatFormatting.DARK_RED),
)))
}
result.add(Either.right(ChartTooltipElement(charts, if (verbose.asBoolean) 200f else 100f, if (verbose.asBoolean) 120f else 60f, levelLabels = labels))) result.add(Either.right(ChartTooltipElement(charts, if (verbose.asBoolean) 200f else 100f, if (verbose.asBoolean) 120f else 60f, levelLabels = labels)))
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 898 B