From 7664b0b4de0d938102df11cdb4d3cff4db4a65bb Mon Sep 17 00:00:00 2001 From: DBotThePony Date: Fri, 15 Nov 2024 13:41:16 +0700 Subject: [PATCH] Significantly improve chart clarity in tooltips --- .../mc/otm/client/render/ChartRendering.kt | 24 ++- .../dbotthepony/mc/otm/config/ClientConfig.kt | 1 - .../mc/otm/core/chart/DecimalHistoryChart.kt | 26 --- .../mc/otm/core/chart/IHistoryChart.kt | 3 + .../mc/otm/core/math/Clustering.kt | 175 ++++++++++++++++ .../mc/otm/core/util/Formatting.kt | 197 +++++++++++++++--- .../textures/gui/widgets/radio.png | Bin 898 -> 0 bytes 7 files changed, 368 insertions(+), 58 deletions(-) create mode 100644 src/main/kotlin/ru/dbotthepony/mc/otm/core/math/Clustering.kt delete mode 100644 src/main/resources/assets/overdrive_that_matters/textures/gui/widgets/radio.png diff --git a/src/main/kotlin/ru/dbotthepony/mc/otm/client/render/ChartRendering.kt b/src/main/kotlin/ru/dbotthepony/mc/otm/client/render/ChartRendering.kt index cbd6e7a4e..f9ed7eae6 100644 --- a/src/main/kotlin/ru/dbotthepony/mc/otm/client/render/ChartRendering.kt +++ b/src/main/kotlin/ru/dbotthepony/mc/otm/client/render/ChartRendering.kt @@ -21,6 +21,7 @@ import org.lwjgl.opengl.GL11.GL_LESS import ru.dbotthepony.kommons.math.RGBAColor import ru.dbotthepony.mc.otm.client.minecraft import kotlin.math.PI +import kotlin.math.absoluteValue import kotlin.math.acos import kotlin.math.cos import kotlin.math.pow @@ -287,10 +288,31 @@ fun renderChart( val font = levelLabels.font ?: minecraft.font 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 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( poseStack, diff --git a/src/main/kotlin/ru/dbotthepony/mc/otm/config/ClientConfig.kt b/src/main/kotlin/ru/dbotthepony/mc/otm/config/ClientConfig.kt index 2af9e4458..03c74878e 100644 --- a/src/main/kotlin/ru/dbotthepony/mc/otm/config/ClientConfig.kt +++ b/src/main/kotlin/ru/dbotthepony/mc/otm/config/ClientConfig.kt @@ -50,7 +50,6 @@ object ClientConfig : AbstractConfig("client", ModConfig.Type.CLIENT) { var CHARTS_IN_TOOLTIPS: Boolean by builder .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) init { diff --git a/src/main/kotlin/ru/dbotthepony/mc/otm/core/chart/DecimalHistoryChart.kt b/src/main/kotlin/ru/dbotthepony/mc/otm/core/chart/DecimalHistoryChart.kt index afa628c2c..66c350868 100644 --- a/src/main/kotlin/ru/dbotthepony/mc/otm/core/chart/DecimalHistoryChart.kt +++ b/src/main/kotlin/ru/dbotthepony/mc/otm/core/chart/DecimalHistoryChart.kt @@ -1,10 +1,8 @@ package ru.dbotthepony.mc.otm.core.chart -import com.google.common.collect.ImmutableList import com.mojang.serialization.Codec import net.minecraft.network.RegistryFriendlyByteBuf 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.data.DecimalCodec import ru.dbotthepony.mc.otm.network.MatteryStreamCodec @@ -33,28 +31,4 @@ class DecimalHistoryChart : AbstractHistoryChart { get() = DecimalCodec override val streamCodec: MatteryStreamCodec get() = DecimalCodec.NETWORK - - companion object { - private val HISTORY_WEIGHTERS: ImmutableList = 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 - } - } } diff --git a/src/main/kotlin/ru/dbotthepony/mc/otm/core/chart/IHistoryChart.kt b/src/main/kotlin/ru/dbotthepony/mc/otm/core/chart/IHistoryChart.kt index 3a97ca45e..7c766163f 100644 --- a/src/main/kotlin/ru/dbotthepony/mc/otm/core/chart/IHistoryChart.kt +++ b/src/main/kotlin/ru/dbotthepony/mc/otm/core/chart/IHistoryChart.kt @@ -1,5 +1,8 @@ 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 */ diff --git a/src/main/kotlin/ru/dbotthepony/mc/otm/core/math/Clustering.kt b/src/main/kotlin/ru/dbotthepony/mc/otm/core/math/Clustering.kt new file mode 100644 index 000000000..69b07bae9 --- /dev/null +++ b/src/main/kotlin/ru/dbotthepony/mc/otm/core/math/Clustering.kt @@ -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 { + val values: List + val center: V + + data class Impl(override val values: List, override val center: V) : Cluster +} + +private class ClusterValue>(val value: V, var cluster: MutableCluster, var error: V) { + inline fun updateError(abs: (V) -> V, minus: (V, V) -> V) { + error = abs(minus(cluster.center, value)) + } + + inline fun maybeSwitchCluster(cluster: MutableCluster, 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>(var center: V) { + val values = ArrayList>() + + 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 > Iterable.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> { + 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>() + val values = ArrayList>() + 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>() + 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? = 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.clusterize(random: RandomGenerator, clusters: Int? = null): List> { + 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 + } +} diff --git a/src/main/kotlin/ru/dbotthepony/mc/otm/core/util/Formatting.kt b/src/main/kotlin/ru/dbotthepony/mc/otm/core/util/Formatting.kt index c5b30d2a9..9b83d0c7a 100644 --- a/src/main/kotlin/ru/dbotthepony/mc/otm/core/util/Formatting.kt +++ b/src/main/kotlin/ru/dbotthepony/mc/otm/core/util/Formatting.kt @@ -15,10 +15,10 @@ import ru.dbotthepony.mc.otm.config.ClientConfig import ru.dbotthepony.mc.otm.core.TextComponent import ru.dbotthepony.mc.otm.core.TranslatableComponent 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.isZero import ru.dbotthepony.mc.otm.menu.widget.IProfiledLevelGaugeWidget -import ru.dbotthepony.mc.otm.menu.widget.ProfiledLevelGaugeWidget import java.math.BigInteger import java.util.function.BooleanSupplier import kotlin.math.absoluteValue @@ -314,34 +314,34 @@ private fun formatHistoryChart( if (widget.received[it].isInfinite && widget.transferred[it].isInfinite) 0.5f else if (diff[it].isInfinite && diff[it].isNegative) - 0.1f + 0f else if (diff[it].isInfinite && diff[it].isPositive) - 0.9f + 1f else 0.5f } 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) { - if (widget.transferred[it].isInfinite) 0.1f else 0.5f + if (widget.transferred[it].isInfinite) 0f else 0.5f } labels = ChartLevelLabels( labels = mapOf( 0.5f to Decimal.ZERO.formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias), - 0.1f to TextComponent("-∞"), - 0.9f to TextComponent("∞"), + 0f to TextComponent("-∞"), + 1f to TextComponent("∞"), ) ) } else if (hasPositiveInfinity) { normalizedDiff = FloatArray(diff.size) { if (diff[it].isInfinite) - 0.9f + 1f else - 0.5f + 0f } received = FloatArray(diff.size) { @@ -353,16 +353,16 @@ private fun formatHistoryChart( labels = ChartLevelLabels( labels = mapOf( - 0.5f to Decimal.ZERO.formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias), - 0.9f to TextComponent("∞"), + 0f to Decimal.ZERO.formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias), + 1f to TextComponent("∞"), ) ) } else if (hasNegativeInfinity) { normalizedDiff = FloatArray(diff.size) { if (diff[it].isInfinite) - 0.1f + 0f else - 0.5f + 1f } received = FloatArray(diff.size) @@ -374,17 +374,16 @@ private fun formatHistoryChart( labels = ChartLevelLabels( labels = mapOf( - 0.5f to Decimal.ZERO.formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias), - 0.1f to TextComponent("-∞"), + 1f to Decimal.ZERO.formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias), + 0f to TextComponent("-∞"), ) ) } 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() - labelNames[0.5f] = Decimal.ZERO.formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias) - - if (max.isZero) { + if (maxTransferred.isZero && maxReceived.isZero) { normalizedDiff = FloatArray(diff.size) normalizedDiff.fill(0.5f) @@ -394,25 +393,131 @@ private fun formatHistoryChart( transferred = FloatArray(diff.size) transferred.fill(0.5f) - labelNames[0.1f] = TextComponent("-∞") - labelNames[0.9f] = TextComponent("∞") + labelNames[0f] = TextComponent("-∞") + labelNames[0.5f] = Decimal.ZERO.formatSiComponent(suffix, decimals, formatAsReadable = verbose, bias = bias) + labelNames[1f] = TextComponent("∞") } 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) { - (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 + } } - received = FloatArray(diff.size) { - (widget.received[it] / max).toFloat() * 0.4f + 0.5f + if (maxReceived.isNotZero) { + received = FloatArray(diff.size) { + zero + (widget.received[it] / maxReceived).toFloat() * receivedMult + } + } else { + received = FloatArray(diff.size) + received.fill(1f) } - transferred = FloatArray(diff.size) { - (widget.transferred[it] / max).toFloat() * -0.4f + 0.5f + if (maxTransferred.isNotZero) { + transferred = FloatArray(diff.size) { + 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 (verbose.asBoolean) labelNames[0.3f] = (-max * Decimal.ONE_HALF).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) + if (maxTransferred.isNotZero && maxReceived.isNotZero) + labelNames[zero] = Decimal.ZERO.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) @@ -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))) } diff --git a/src/main/resources/assets/overdrive_that_matters/textures/gui/widgets/radio.png b/src/main/resources/assets/overdrive_that_matters/textures/gui/widgets/radio.png deleted file mode 100644 index 831267b7cc7d126da6d8e74fa195d91b9ece7dfe..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 898 zcmV-|1AY97P)4Tx04R}tkv&MmKpe$iQ?;TM5j%(oW~fefQ4z;d#UfZJZG~1HOfLNpnlvOS zE{=k0!NHHks)LKOt`4q(Aou~|?BJy6A|?JWDYS_3;J6>}?mh0_0YbgZRI?)rsG4P@ z;xRFsTM+}V=tUOU4&iNlJjQNECM zS>e3JSuIyt^Pc>L!MwJd<~q$0#IcA3k`N)IiVc)uAwsK0iis5M$2|PQjz38*nOqxS zm98{ps& z7%x)xn#a4l+k5->OtZfqWi@i4S_GAX00006VoOIv0RI600RN!9r;`8x010qNS#tmY z4#5Bb4#5Gqk!$S$000McNliru=nDn~CKDyoU6TL+0e?wEK~z}7?U>QAgdhw>$qp@P zDcFc**oc&(MX^5%!v+E-%JSxEzE*cmZiIL<2r3Py=m1rY!z^IP7(M>`QeD;8vVjIuWa>Ki72)zo%)s{E1&R(cE=2;a|kx62^PlVvot@Y9W?$ zu6r5CDX%uhC~K|QtA9S7lIC)bW&z5&99Bn=Q%b32SxSlBCkf>4b6D^vUM7t9w8b8i&DBCI@y~l12*+>zYbEh&%mQWR z#Dsy$bUMs-Tk2#ij^ijEiySs|W(mTvZQEB@OW3|+kyCM8d2M-1tga=NCKq{iESkH1 z#^Pndcu!mGG1*)##Ij@YJ_f?^TmM=~yc)CJ#-hHmsW@y99)_WMxc2AYec!*Tm$2YL z?sp8IK+5*(YHr1uZyF-@IW0b?#s7C&)E^@d8;F$cKNgSptv3ycS7SCAb3REfu;3Z? Y4X-yx`AzHXcmMzZ07*qoM6N<$g7|ot)&Kwi