Zoom in/out of the history charts (#9660)

* Do not recreate the Line Chart every time

* Simplifed the Line Chart creation

* Do not create objects in draw(): VictoryScreenCivGroup table

* Do not create objects in draw(): Labels

* Create labels without negative Y

* Lift the X axis if there is an negative number

* Arbitrary number of -Y labels

* Autoscale by Y axis

* Zoom in/out by click

* Autoscale by X axis

* Unit tests for LineChart

* Rework of the line chart rendering
This commit is contained in:
Jack Rainy 2023-06-25 09:37:08 +03:00 committed by GitHub
parent 1285133884
commit 82ebb01a20
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 338 additions and 169 deletions

View File

@ -2,35 +2,28 @@ package com.unciv.ui.components
import com.badlogic.gdx.graphics.Color import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.graphics.g2d.Batch import com.badlogic.gdx.graphics.g2d.Batch
import com.badlogic.gdx.graphics.glutils.ShapeRenderer import com.badlogic.gdx.math.MathUtils.lerp
import com.badlogic.gdx.math.Matrix4
import com.badlogic.gdx.math.Vector2
import com.badlogic.gdx.scenes.scene2d.ui.Image
import com.badlogic.gdx.scenes.scene2d.ui.Label import com.badlogic.gdx.scenes.scene2d.ui.Label
import com.badlogic.gdx.scenes.scene2d.ui.Widget import com.badlogic.gdx.scenes.scene2d.ui.WidgetGroup
import com.badlogic.gdx.utils.Align import com.badlogic.gdx.utils.Align
import com.unciv.logic.civilization.Civilization import com.unciv.logic.civilization.Civilization
import com.unciv.ui.components.extensions.surroundWithCircle import com.unciv.ui.images.ImageGetter
import com.unciv.ui.screens.victoryscreen.VictoryScreenCivGroup import com.unciv.ui.screens.victoryscreen.VictoryScreenCivGroup
import com.unciv.ui.screens.victoryscreen.VictoryScreenCivGroup.DefeatedPlayerStyle import com.unciv.ui.screens.victoryscreen.VictoryScreenCivGroup.DefeatedPlayerStyle
import kotlin.math.abs
import kotlin.math.min import kotlin.math.min
import kotlin.math.ceil import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.log10 import kotlin.math.log10
import kotlin.math.max import kotlin.math.max
import kotlin.math.pow import kotlin.math.pow
import kotlin.math.sqrt import kotlin.math.sqrt
private data class DataPoint<T>(val x: T, val y: T, val civ: Civilization) data class DataPoint<T>(val x: T, val y: T, val civ: Civilization)
class LineChart( class LineChart(
data: Map<Int, Map<Civilization, Int>>, private val viewingCiv: Civilization
private val viewingCiv: Civilization, ) : WidgetGroup() {
private val selectedCiv: Civilization,
private val chartWidth: Float,
private val chartHeight: Float
) : Widget() {
private val shapeRenderer = ShapeRenderer()
private val axisLineWidth = 2f private val axisLineWidth = 2f
private val axisColor = Color.WHITE private val axisColor = Color.WHITE
@ -45,40 +38,85 @@ class LineChart(
* as `0` is not counted. */ * as `0` is not counted. */
private val maxLabels = 10 private val maxLabels = 10
private val xLabels: List<Int> private var xLabels = emptyList<Int>()
private val yLabels: List<Int> private var yLabels = emptyList<Int>()
private var xLabelsAsLabels = emptyList<Label>()
private var yLabelsAsLabels = emptyList<Label>()
private val hasNegativeYValues: Boolean private var dataPoints = emptyList<DataPoint<Int>>()
private val negativeYLabel: Int private var selectedCiv = Civilization()
private val dataPoints: List<DataPoint<Int>> = data.flatMap { turn ->
turn.value.map { (civ, value) ->
DataPoint(turn.key, value, civ) fun getTurnAt(x: Float): IntRange? {
if (xLabels.isEmpty() || xLabelsAsLabels.isEmpty() || yLabelsAsLabels.isEmpty()) return null
val widestYLabelWidth = yLabelsAsLabels.maxOf { it.width }
val linesMinX = widestYLabelWidth + axisToLabelPadding + axisLineWidth
val linesMaxX = width - xLabelsAsLabels.last().width / 2
if (linesMinX.compareTo(linesMaxX) == 0) return (xLabels.first()..xLabels.last())
val ratio = (x - linesMinX) / (linesMaxX - linesMinX)
val turn = max(1, lerp(xLabels.first().toFloat(), xLabels.last().toFloat(), ratio).toInt())
return (getPrevNumberDivisibleByPowOfTen(turn-1)..getNextNumberDivisibleByPowOfTen(turn+1))
}
fun update(newData: List<DataPoint<Int>>, newSelectedCiv: Civilization) {
selectedCiv = newSelectedCiv
dataPoints = newData
updateLabels(dataPoints)
prepareForDraw()
}
private fun updateLabels(newData: List<DataPoint<Int>>) {
xLabels = generateLabels(newData, false)
yLabels = generateLabels(newData, true)
xLabelsAsLabels =
xLabels.map { Label(it.toString(), Label.LabelStyle(Fonts.font, axisLabelColor)) }
yLabelsAsLabels =
yLabels.map { Label(it.toString(), Label.LabelStyle(Fonts.font, axisLabelColor)) }
}
fun generateLabels(value: List<DataPoint<Int>>, yAxis: Boolean): List<Int> {
if (value.isEmpty()) return listOf(0)
val minLabelValue = getPrevNumberDivisibleByPowOfTen(value.minOf { if (yAxis) it.y else it.x })
val maxLabelValue = getNextNumberDivisibleByPowOfTen(value.maxOf { if (yAxis) it.y else it.x })
var stepSizePositive = ceil(maxLabelValue.toFloat() / maxLabels).toInt()
return when {
minLabelValue < 0 -> {
var stepSizeNegative = ceil(-minLabelValue.toFloat() / maxLabels).toInt()
val maxStep = max(stepSizePositive, stepSizeNegative)
val stepCountNegative = floor(minLabelValue / maxStep.toDouble()).toInt()
stepSizeNegative = if (abs(stepCountNegative) < 2) abs(minLabelValue) else maxStep
val stepCountPositive = ceil(maxLabelValue / maxStep.toDouble()).toInt()
stepSizePositive = if (abs(stepCountPositive) < 2) abs(maxLabelValue) else maxStep
(stepCountNegative until 0).map { (it * stepSizeNegative) } +
if (maxLabelValue != 0)
(0 until stepCountPositive + 1).map { (it * stepSizePositive) }
else listOf(0)
}
maxLabelValue != 0 -> {
// `maxLabels + 1` because we want to end at `maxLabels * stepSize`.
if (minLabelValue < stepSizePositive)
(0 until maxLabels + 1).map { (it * stepSizePositive) }
else {
stepSizePositive = ceil((maxLabelValue-minLabelValue).toFloat() / maxLabels).toInt()
(0 until maxLabels + 1).map { minLabelValue + (it * stepSizePositive) }
}
}
else -> listOf(0, 1) // line along 0
} }
} }
init {
hasNegativeYValues = dataPoints.any { it.y < 0 }
xLabels = generateLabels(dataPoints.maxOf { it.x })
yLabels = generateLabels(dataPoints.maxOf { it.y })
val lowestValue = dataPoints.minOf { it.y }
negativeYLabel = if (hasNegativeYValues) -getNextNumberDivisibleByPowOfTen(-lowestValue) else 0
}
private fun generateLabels(maxValue: Int): List<Int> {
val maxLabelValue = getNextNumberDivisibleByPowOfTen(maxValue)
val stepSize = ceil(maxLabelValue.toFloat() / maxLabels).toInt()
// `maxLabels + 1` because we want to end at `maxLabels * stepSize`.
return (0 until maxLabels + 1).map { (it * stepSize) }
}
/** /**
* Returns the next number of power 10, with maximal step <= 100. * Returns the next number of power 10, with maximal step <= 100.
* Examples: 0 => 0, 3 => 10, 97 => 100, 567 => 600, 123321 => 123400 * Examples: 0 => 0, 3 => 10, 97 => 100, 567 => 600, 123321 => 123400
*/ */
private fun getNextNumberDivisibleByPowOfTen(value: Int): Int { private fun getNextNumberDivisibleByPowOfTen(value: Int): Int {
if (value == 0) return 0 if (value == 0) return 0
val numberOfDigits = min(ceil(log10(value.toDouble())).toInt(), 3) val numberOfDigits = max(2, min(ceil(log10(abs(value).toDouble())).toInt(), 3))
val oneWithZeros = 10.0.pow(numberOfDigits - 1) val oneWithZeros = 10.0.pow(numberOfDigits - 1)
// E.g., 3 => 10^(2-1) = 10 ; ceil(3 / 10) * 10 = 10 // E.g., 3 => 10^(2-1) = 10 ; ceil(3 / 10) * 10 = 10
// 567 => 10^(3-1) = 100 ; ceil(567 / 100) * 100 = 600 // 567 => 10^(3-1) = 100 ; ceil(567 / 100) * 100 = 600
@ -86,28 +124,39 @@ class LineChart(
return (ceil(value / oneWithZeros) * oneWithZeros).toInt() return (ceil(value / oneWithZeros) * oneWithZeros).toInt()
} }
/**
* Returns the previous number of power 10, with maximal step <= 100.
* Examples: 0 => 0, -3 => -10, 97 => 90, 567 => 500, 123321 => 123300
*/
private fun getPrevNumberDivisibleByPowOfTen(value: Int): Int {
if (value == 0) return 0
val numberOfDigits = max(2 , min(ceil(log10(abs(value).toDouble())).toInt(), 3))
val oneWithZeros = 10.0.pow(numberOfDigits - 1)
// E.g., 3 => 10^(2-1) = 10 ; floor(3 / 10) * 10 = 0
// 567 => 10^(3-1) = 100 ; floor(567 / 100) * 100 = 500
// 123321 => 10^(3-1) = 100 ; floor(123321 / 100) * 100 = 123300
return (floor(value / oneWithZeros) * oneWithZeros).toInt()
}
override fun draw(batch: Batch, parentAlpha: Float) { override fun draw(batch: Batch, parentAlpha: Float) {
super.draw(batch, parentAlpha) super.draw(batch, parentAlpha)
}
// Save the current batch transformation matrix private fun prepareForDraw() {
val oldTransformMatrix = batch.transformMatrix.cpy()
// Set the batch transformation matrix to the local coordinates of the LineChart widget clearChildren()
val stageCoords = localToStageCoordinates(Vector2(0f, 0f))
batch.transformMatrix = Matrix4().translate(stageCoords.x, stageCoords.y, 0f) if (xLabels.isEmpty() || yLabels.isEmpty()) return
val lastTurnDataPoints = getLastTurnDataPoints() val lastTurnDataPoints = getLastTurnDataPoints()
val labelHeight = Label("123", Label.LabelStyle(Fonts.font, axisLabelColor)).height val labelHeight = yLabelsAsLabels.first().height
val yLabelsAsLabels = val widestYLabelWidth = yLabelsAsLabels.maxOf { it.width }
yLabels.map { Label(it.toString(), Label.LabelStyle(Fonts.font, axisLabelColor)) }
val negativeYLabelAsLabel =
Label(negativeYLabel.toString(), Label.LabelStyle(Fonts.font, axisLabelColor))
val widestYLabelWidth = max(yLabelsAsLabels.maxOf { it.width }, negativeYLabelAsLabel.width)
// We assume here that all labels have the same height. We need to deduct the height of // We assume here that all labels have the same height. We need to deduct the height of
// a label from the available height, because otherwise the label on the top would // a label from the available height, because otherwise the label on the top would
// overrun the height since the (x,y) coordinates define the bottom left corner of the // overrun the height since the (x,y) coordinates define the bottom left corner of the
// Label. // Label.
val yAxisLabelMaxY = chartHeight - labelHeight val yAxisLabelMaxY = height - labelHeight
// This is to account for the x axis and its labels which are below the lowest point // This is to account for the x axis and its labels which are below the lowest point
val xAxisLabelsHeight = labelHeight val xAxisLabelsHeight = labelHeight
val zeroYAxisLabelHeight = labelHeight val zeroYAxisLabelHeight = labelHeight
@ -118,63 +167,60 @@ class LineChart(
// We draw the y-axis labels first. They will take away some space on the left of the // We draw the y-axis labels first. They will take away some space on the left of the
// widget which we need to consider when drawing the rest of the graph. // widget which we need to consider when drawing the rest of the graph.
var yAxisYPosition = 0f var yAxisYPosition = 0f
val negativeOrientationLineYPosition = yAxisLabelMinY + labelHeight / 2 yLabels.forEachIndexed { index, value ->
val yLabelsToDraw = if (hasNegativeYValues) listOf(negativeYLabelAsLabel) + yLabelsAsLabels else yLabelsAsLabels val label = yLabelsAsLabels[index] // we assume yLabels.size == yLabelsAsLabels.size
yLabelsToDraw.forEachIndexed { index, label -> val yPos = yAxisLabelMinY + index * (yAxisLabelYRange / (yLabels.size - 1))
val yPos = yAxisLabelMinY + index * (yAxisLabelYRange / (yLabelsToDraw.size - 1))
label.setPosition((widestYLabelWidth - label.width) / 2, yPos) label.setPosition((widestYLabelWidth - label.width) / 2, yPos)
label.draw(batch, 1f) addActor(label)
// Draw y-axis orientation lines and x-axis // Draw y-axis orientation lines and x-axis
val zeroIndex = if (hasNegativeYValues) 1 else 0 val zeroIndex = value == 0
drawLine( drawLine(
batch,
widestYLabelWidth + axisToLabelPadding + axisLineWidth, widestYLabelWidth + axisToLabelPadding + axisLineWidth,
yPos + labelHeight / 2, yPos + labelHeight / 2,
chartWidth, width,
yPos + labelHeight / 2, yPos + labelHeight / 2,
if (index != zeroIndex) orientationLineColor else axisColor, if (zeroIndex) axisColor else orientationLineColor,
if (index != zeroIndex) orientationLineWidth else axisLineWidth if (zeroIndex) axisLineWidth else orientationLineWidth
) )
if (index == zeroIndex) { if (zeroIndex) {
yAxisYPosition = yPos + labelHeight / 2 yAxisYPosition = yPos + labelHeight / 2
} }
} }
// Draw x-axis labels // Draw x-axis labels
val xLabelsAsLabels = val lastXAxisLabelWidth = xLabelsAsLabels.last().width
xLabels.map { Label(it.toString(), Label.LabelStyle(Fonts.font, axisLabelColor)) }
val lastXAxisLabelWidth = xLabelsAsLabels[xLabelsAsLabels.size - 1].width
val xAxisLabelMinX = val xAxisLabelMinX =
widestYLabelWidth + axisToLabelPadding + axisLineWidth / 2 widestYLabelWidth + axisToLabelPadding + axisLineWidth / 2
val xAxisLabelMaxX = chartWidth - lastXAxisLabelWidth / 2 val xAxisLabelMaxX = width - lastXAxisLabelWidth / 2
val xAxisLabelXRange = xAxisLabelMaxX - xAxisLabelMinX val xAxisLabelXRange = xAxisLabelMaxX - xAxisLabelMinX
xLabels.forEachIndexed { index, labelAsInt -> xLabelsAsLabels.forEachIndexed { index, label ->
val label = Label(labelAsInt.toString(), Label.LabelStyle(Fonts.font, axisLabelColor))
val xPos = xAxisLabelMinX + index * (xAxisLabelXRange / (xLabels.size - 1)) val xPos = xAxisLabelMinX + index * (xAxisLabelXRange / (xLabels.size - 1))
label.setPosition(xPos - label.width / 2, 0f) label.setPosition(xPos - label.width / 2, 0f)
label.draw(batch, 1f) addActor(label)
// Draw x-axis orientation lines and y-axis // Draw x-axis orientation lines and y-axis
drawLine( drawLine(
batch,
xPos, xPos,
labelHeight + axisToLabelPadding + axisLineWidth, labelHeight + axisToLabelPadding + axisLineWidth,
xPos, xPos,
chartHeight, height,
if (index > 0) orientationLineColor else axisColor, if (index > 0) orientationLineColor else axisColor,
if (index >0) orientationLineWidth else axisLineWidth if (index > 0) orientationLineWidth else axisLineWidth
) )
} }
// Draw line charts for each color // Draw line charts for each color
val linesMinX = widestYLabelWidth + axisToLabelPadding + axisLineWidth val linesMinX = widestYLabelWidth + axisToLabelPadding + axisLineWidth
val linesMaxX = chartWidth - lastXAxisLabelWidth / 2 val linesMaxX = width - lastXAxisLabelWidth / 2
val linesMinY = yAxisYPosition val linesMinY = yAxisYPosition
val linesMaxY = chartHeight - labelHeight / 2 val linesMaxY = height - labelHeight / 2
val scaleX = (linesMaxX - linesMinX) / xLabels.max() val scaleX = (linesMaxX - linesMinX) / (xLabels.max() - xLabels.min())
val scaleY = (linesMaxY - linesMinY) / yLabels.max() val scaleY = (linesMaxY - linesMinY) / (yLabels.max() - yLabels.min())
val negativeScaleY = if (hasNegativeYValues) (linesMinY - negativeOrientationLineYPosition) / -negativeYLabel else 0f val negativeOrientationLineYPosition = yAxisLabelMinY + labelHeight / 2
val minXLabel = xLabels.min()
val minYLabel = yLabels.min()
val negativeScaleY = (negativeOrientationLineYPosition - linesMinY) / if (minYLabel < 0) minYLabel else 1
val sortedPoints = dataPoints.sortedBy { it.x } val sortedPoints = dataPoints.sortedBy { it.x }
val pointsByCiv = sortedPoints.groupBy { it.civ } val pointsByCiv = sortedPoints.groupBy { it.civ }
// We want the current player civ to be drawn last, so it is never overlapped by another player. // We want the current player civ to be drawn last, so it is never overlapped by another player.
@ -191,8 +237,10 @@ class LineChart(
for (civ in civIterationOrder) { for (civ in civIterationOrder) {
val points = pointsByCiv[civ]!! val points = pointsByCiv[civ]!!
val scaledPoints : List<DataPoint<Float>> = points.map { val scaledPoints : List<DataPoint<Float>> = points.map {
val yScale = if (it.y < 0f) negativeScaleY else scaleY if (it.y < 0f)
DataPoint(linesMinX + it.x * scaleX, linesMinY + it.y * yScale, it.civ) DataPoint(linesMinX + (it.x - minXLabel) * scaleX, linesMinY + it.y * negativeScaleY, it.civ)
else
DataPoint(linesMinX + (it.x - minXLabel) * scaleX, linesMinY + (it.y - minYLabel) * scaleY, it.civ)
} }
// Probably nobody can tell the difference of one pixel, so that seems like a reasonable epsilon. // Probably nobody can tell the difference of one pixel, so that seems like a reasonable epsilon.
val simplifiedScaledPoints = douglasPeucker(scaledPoints, 1f) val simplifiedScaledPoints = douglasPeucker(scaledPoints, 1f)
@ -205,7 +253,7 @@ class LineChart(
val selectedCivBackgroundColor = val selectedCivBackgroundColor =
if (useActualColor(civ)) civ.nation.getInnerColor() else Color.LIGHT_GRAY if (useActualColor(civ)) civ.nation.getInnerColor() else Color.LIGHT_GRAY
drawLine( drawLine(
batch, a.x, a.y, b.x, b.y, a.x, a.y, b.x, b.y,
selectedCivBackgroundColor, chartLineWidth * 3 selectedCivBackgroundColor, chartLineWidth * 3
) )
} }
@ -214,31 +262,19 @@ class LineChart(
val a = simplifiedScaledPoints[i - 1] val a = simplifiedScaledPoints[i - 1]
val b = simplifiedScaledPoints[i] val b = simplifiedScaledPoints[i]
val civLineColor = if (useActualColor(civ)) civ.nation.getOuterColor() else Color.DARK_GRAY val civLineColor = if (useActualColor(civ)) civ.nation.getOuterColor() else Color.DARK_GRAY
drawLine(batch, a.x, a.y, b.x, b.y, civLineColor, chartLineWidth) drawLine(a.x, a.y, b.x, b.y, civLineColor, chartLineWidth)
// Draw the selected Civ icon on its last datapoint // Draw the selected Civ icon on its last datapoint
if (i == simplifiedScaledPoints.size - 1 && selectedCiv == civ && selectedCiv in lastTurnDataPoints) { if (i == simplifiedScaledPoints.size - 1 && selectedCiv == civ && selectedCiv in lastTurnDataPoints) {
val selectedCivIcon = val selectedCivIcon = VictoryScreenCivGroup.getCivImageAndColors(selectedCiv, viewingCiv, DefeatedPlayerStyle.REGULAR).first
VictoryScreenCivGroup( selectedCivIcon.apply {
selectedCiv,
"",
viewingCiv,
DefeatedPlayerStyle.REGULAR
).children[0].run {
(this as? Image)?.surroundWithCircle(30f, color = Color.LIGHT_GRAY)
?: this
}
selectedCivIcon.run {
setPosition(b.x, b.y, Align.center) setPosition(b.x, b.y, Align.center)
setSize(33f, 33f) // Dead Civs need this setSize(33f, 33f) // Dead Civs need this
draw(batch, parentAlpha)
} }
addActor(selectedCivIcon)
} }
} }
} }
// Restore the previous batch transformation matrix
batch.transformMatrix = oldTransformMatrix
} }
private fun useActualColor(civ: Civilization) : Boolean { private fun useActualColor(civ: Civilization) : Boolean {
@ -260,29 +296,18 @@ class LineChart(
return lastDataPoints return lastDataPoints
} }
private fun drawLine( private fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float, lineColor: Color, width: Float) {
batch: Batch,
x1: Float,
y1: Float,
x2: Float,
y2: Float,
color: Color,
width: Float
) {
shapeRenderer.projectionMatrix = batch.projectionMatrix
shapeRenderer.transformMatrix = batch.transformMatrix
batch.end()
shapeRenderer.begin(ShapeRenderer.ShapeType.Filled) val line = ImageGetter.getLine(x1, y1, x2, y2, width)
shapeRenderer.color = color line.color = lineColor
shapeRenderer.rectLine(x1, y1, x2, y2, width) addActor(line)
// Draw a circle at the beginning and end points of the line to make consecutive lines
// (which might point in different directions) connect nicely.
shapeRenderer.circle(x1, y1, width / 2)
shapeRenderer.circle(x2, y2, width / 2)
shapeRenderer.end()
batch.begin() val edgeRounding = ImageGetter.getCircle().apply {
setSize(width, width)
color = lineColor
setPosition(x1 - width / 2f, y1 - width / 2f)
}
addActor(edgeRounding)
} }
private fun douglasPeucker(points: List<DataPoint<Float>>, epsilon: Float): List<DataPoint<Float>> { private fun douglasPeucker(points: List<DataPoint<Float>>, epsilon: Float): List<DataPoint<Float>> {
@ -352,10 +377,4 @@ class LineChart(
return sqrt((dx * dx + dy * dy).toDouble()).toFloat() return sqrt((dx * dx + dy * dy).toDouble()).toFloat()
} }
override fun getMinWidth() = chartWidth
override fun getMinHeight() = chartHeight
override fun getPrefWidth() = chartWidth
override fun getPrefHeight() = chartHeight
override fun getMaxWidth() = chartWidth
override fun getMaxHeight() = chartHeight
} }

View File

@ -2,16 +2,16 @@ package com.unciv.ui.screens.victoryscreen
import com.badlogic.gdx.graphics.Color import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.Touchable import com.badlogic.gdx.scenes.scene2d.Touchable
import com.badlogic.gdx.scenes.scene2d.ui.Container
import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.utils.Align import com.badlogic.gdx.utils.Align
import com.unciv.logic.civilization.Civilization
import com.unciv.ui.components.AutoScrollPane import com.unciv.ui.components.AutoScrollPane
import com.unciv.ui.components.DataPoint
import com.unciv.ui.components.LineChart import com.unciv.ui.components.LineChart
import com.unciv.ui.components.TabbedPager import com.unciv.ui.components.TabbedPager
import com.unciv.ui.components.input.onChange import com.unciv.ui.components.input.onChange
import com.unciv.ui.components.input.onClick import com.unciv.ui.components.input.onClick
import com.unciv.ui.components.extensions.packIfNeeded import com.unciv.ui.components.extensions.packIfNeeded
import com.unciv.ui.components.input.OnClickListener
import com.unciv.ui.images.ImageGetter import com.unciv.ui.images.ImageGetter
import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.screens.newgamescreen.TranslatedSelectBox import com.unciv.ui.screens.newgamescreen.TranslatedSelectBox
@ -36,7 +36,9 @@ class VictoryScreenCharts(
align = Align.center align = Align.center
} }
private val chartHolder = Container<LineChart?>(null) private var lineChart = LineChart(viewingCiv)
// if it is negative - no zoom, if positive - zoom at turn X
private var zoomAtX : IntRange? = null
init { init {
civButtonsScroll.setScrollingDisabled(true, false) civButtonsScroll.setScrollingDisabled(true, false)
@ -46,7 +48,14 @@ class VictoryScreenCharts(
controlsColumn.add(civButtonsScroll).fillY() controlsColumn.add(civButtonsScroll).fillY()
defaults().fill().pad(20f) defaults().fill().pad(20f)
add(controlsColumn) add(controlsColumn)
add(chartHolder).growX().top().padLeft(0f) updateControls()
add(lineChart).growX().top().padLeft(0f)
val onChartClick = OnClickListener(function = { _ , x, _ ->
zoomAtX = if (zoomAtX == null) lineChart.getTurnAt(x) else null
updateChart()
})
lineChart.addListener(onChartClick)
rankingTypeSelect.onChange { rankingTypeSelect.onChange {
rankingType = RankingType.values() rankingType = RankingType.values()
@ -85,38 +94,31 @@ class VictoryScreenCharts(
} }
private fun updateChart() { private fun updateChart() {
// LineChart does not "cooperate" in Layout - the size we set here is final. lineChart.update(getLineChartData(rankingType), selectedCiv)
// These values seem to fit the cell it'll be in - we subtract padding and some extra manually
packIfNeeded() packIfNeeded()
chartHolder.actor = LineChart(
getLineChartData(rankingType),
viewingCiv,
selectedCiv,
parent.width - getColumnWidth(0) - 60f,
parent.height - 60f
)
chartHolder.invalidateHierarchy()
} }
private fun getLineChartData( private fun getLineChartData(rankingType: RankingType): List<DataPoint<Int>> {
rankingType: RankingType
): Map<Int, Map<Civilization, Int>> {
return gameInfo.civilizations.asSequence() return gameInfo.civilizations.asSequence()
.filter { it.isMajorCiv() } .filter { it.isMajorCiv() }
.flatMap { civ -> .flatMap { civ ->
civ.statsHistory civ.statsHistory
.filterKeys { zoomAtX == null || it in zoomAtX!! }
.filterValues { it.containsKey(rankingType) } .filterValues { it.containsKey(rankingType) }
.map { (turn, data) -> Pair(turn, Pair(civ, data.getValue(rankingType))) } .map { (turn, data) -> Pair(turn, Pair(civ, data.getValue(rankingType))) }
} }
.groupBy({ it.first }, { it.second }) .groupBy({ it.first }, { it.second })
.mapValues { group -> group.value.toMap() } .mapValues { group -> group.value.toMap() }
.flatMap { turn ->
turn.value.map { (civ, value) -> DataPoint(turn.key, value, civ) }
}
} }
override fun activated(index: Int, caption: String, pager: TabbedPager) { override fun activated(index: Int, caption: String, pager: TabbedPager) {
pager.setScrollDisabled(true) pager.setScrollDisabled(true)
getCell(controlsColumn).height(parent.height) controlsColumn.height = parent.height
getCell(chartHolder).height(parent.height) lineChart.height = parent.height
if (chartHolder.actor == null) update() update()
civButtonsTable.invalidateHierarchy() civButtonsTable.invalidateHierarchy()
} }

View File

@ -1,11 +1,13 @@
package com.unciv.ui.screens.victoryscreen package com.unciv.ui.screens.victoryscreen
import com.badlogic.gdx.graphics.Color import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.utils.Align import com.badlogic.gdx.utils.Align
import com.unciv.Constants import com.unciv.Constants
import com.unciv.logic.civilization.Civilization import com.unciv.logic.civilization.Civilization
import com.unciv.models.translations.tr import com.unciv.models.translations.tr
import com.unciv.ui.components.extensions.setSize
import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.images.ImageGetter import com.unciv.ui.images.ImageGetter
import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.ui.screens.basescreen.BaseScreen
@ -54,34 +56,17 @@ internal class VictoryScreenCivGroup(
: this(civ, "\n", additionalInfo.tr(), currentPlayer, defeatedPlayerStyle) : this(civ, "\n", additionalInfo.tr(), currentPlayer, defeatedPlayerStyle)
init { init {
var labelText = if (additionalInfo.isEmpty()) civ.civName val labelText =
else "{${civ.civName}}$separator{$additionalInfo}" if (currentPlayer.knows(civ) || currentPlayer == civ ||
val labelColor: Color civ.isDefeated() || currentPlayer.isDefeated()) {
val backgroundColor: Color if (additionalInfo.isEmpty()) civ.civName
else "{${civ.civName}}$separator{$additionalInfo}"
} else Constants.unknownNationName
when { val civInfo = getCivImageAndColors(civ, currentPlayer, defeatedPlayerStyle)
civ.isDefeated() && defeatedPlayerStyle == DefeatedPlayerStyle.GREYED_OUT -> { add(civInfo.first).size(30f)
add(ImageGetter.getImage("OtherIcons/DisbandUnit")).size(30f) val backgroundColor = civInfo.second
backgroundColor = Color.LIGHT_GRAY val labelColor = civInfo.third
labelColor = Color.BLACK
}
currentPlayer.isSpectator()
|| civ.isDefeated() && defeatedPlayerStyle == DefeatedPlayerStyle.REGULAR
|| currentPlayer == civ // || game.viewEntireMapForDebug
|| currentPlayer.knows(civ)
|| currentPlayer.isDefeated()
|| currentPlayer.victoryManager.hasWon() -> {
add(ImageGetter.getNationPortrait(civ.nation, 30f))
backgroundColor = civ.nation.getOuterColor()
labelColor = civ.nation.getInnerColor()
}
else -> {
add(ImageGetter.getRandomNationPortrait(30f))
backgroundColor = Color.DARK_GRAY
labelColor = Color.WHITE
labelText = Constants.unknownNationName
}
}
background = BaseScreen.skinStrings.getUiBackground("VictoryScreen/CivGroup", BaseScreen.skinStrings.roundedEdgeRectangleShape, backgroundColor) background = BaseScreen.skinStrings.getUiBackground("VictoryScreen/CivGroup", BaseScreen.skinStrings.roundedEdgeRectangleShape, backgroundColor)
val label = labelText.toLabel(labelColor, hideIcons = true) val label = labelText.toLabel(labelColor, hideIcons = true)
@ -89,4 +74,27 @@ internal class VictoryScreenCivGroup(
add(label).padLeft(10f) add(label).padLeft(10f)
} }
companion object {
fun getCivImageAndColors(civ: Civilization, currentPlayer: Civilization, defeatedPlayerStyle: DefeatedPlayerStyle): Triple<Actor, Color, Color> {
when {
civ.isDefeated() && defeatedPlayerStyle == DefeatedPlayerStyle.GREYED_OUT -> {
val icon = (ImageGetter.getImage("OtherIcons/DisbandUnit"))
icon.setSize(30f)
return Triple(icon, Color.LIGHT_GRAY, Color.BLACK)
}
currentPlayer.isSpectator()
|| civ.isDefeated() && defeatedPlayerStyle == DefeatedPlayerStyle.REGULAR
|| currentPlayer == civ // || game.viewEntireMapForDebug
|| currentPlayer.knows(civ)
|| currentPlayer.isDefeated()
|| currentPlayer.victoryManager.hasWon() -> {
return Triple(ImageGetter.getNationPortrait(civ.nation, 30f), civ.nation.getOuterColor(), civ.nation.getInnerColor())
}
else ->
return Triple((ImageGetter.getRandomNationPortrait(30f)), Color.LIGHT_GRAY, Color.BLACK)
}
}
}
} }

View File

@ -0,0 +1,140 @@
package com.unciv.ui.components
import com.unciv.logic.civilization.Civilization
import org.junit.Assert
import org.junit.Test
class LineChartTests {
private val civ = Civilization("My civ")
private val lineChart = LineChart(Civilization(civ.civName))
@Test
fun `labels for an empty list are just 0 label`() {
val data = mutableListOf <DataPoint<Int>>()
val result = lineChart.generateLabels(data, true)
Assert.assertTrue(result.size == 1 && result[0] == 0)
}
@Test
fun `chart goes along 0 line`() {
val data = mutableListOf <DataPoint<Int>>()
data.add(DataPoint(0,0, civ))
data.add(DataPoint(1,0, civ))
data.add(DataPoint(2,0, civ))
val result = lineChart.generateLabels(data, true)
Assert.assertTrue(result.size == 2 && result[0] == 0 && result[1] == 1)
}
@Test
fun `chart is from 0 to the positive value`() {
val data = mutableListOf <DataPoint<Int>>()
data.add(DataPoint(0,-100, civ))
data.add(DataPoint(1,200, civ))
data.add(DataPoint(2,1600, civ))
val result = lineChart.generateLabels(data, false) // testing the X axis
Assert.assertTrue(result.size == 11 && result.first() == 0 && result[1] == 1 && result.last() == 10)
}
@Test
fun `chart is from 0 to the negative value`() {
val data = mutableListOf <DataPoint<Int>>()
data.add(DataPoint(0,0, civ))
data.add(DataPoint(1,-2, civ))
data.add(DataPoint(2,-6, civ))
val result = lineChart.generateLabels(data, true) // testing the Y axis
Assert.assertTrue(result.size == 11 && result.first() == -10 && result[1] == -9 && result.last() == 0)
}
@Test
fun `chart goes from the negative to the positive value near 0`() {
val data = mutableListOf <DataPoint<Int>>()
data.add(DataPoint(0,-5, civ))
data.add(DataPoint(1,2, civ))
data.add(DataPoint(2,6, civ))
val result = lineChart.generateLabels(data, true)
Assert.assertTrue(result.size == 21 && result.first() == -10 && result[1] == -9 && result.last() == 10)
}
@Test
fun `chart goes from the negative to the positive far from 0`() {
val data = mutableListOf <DataPoint<Int>>()
data.add(DataPoint(0,-59, civ))
data.add(DataPoint(1,191, civ))
data.add(DataPoint(2,160, civ))
val result = lineChart.generateLabels(data, true)
Assert.assertTrue(result.size == 14 && result.first() == -60 && result[1] == -40 && result[3] == 0 && result.last() == 200)
}
@Test
fun `chart goes from the positive to the negative far from 0`() {
val data = mutableListOf <DataPoint<Int>>()
data.add(DataPoint(0,-485, civ))
data.add(DataPoint(1,191, civ))
data.add(DataPoint(2,160, civ))
val result = lineChart.generateLabels(data, true)
Assert.assertTrue(result.size == 15 && result.first() == -500 && result[1] == -450 && result[10] == 0 && result.last() == 200)
}
@Test
fun `chart is within the positive values far from 0`() {
val data = mutableListOf <DataPoint<Int>>()
data.add(DataPoint(0,290, civ))
data.add(DataPoint(1,1159, civ))
data.add(DataPoint(2,280, civ))
val result = lineChart.generateLabels(data, true)
Assert.assertTrue(result.size == 11 && result.first() == 200 && result[1] == 300 && result.last() == 1200)
}
@Test
fun `chart is within the negative values far from 0`() {
val data = mutableListOf <DataPoint<Int>>()
data.add(DataPoint(0,-290, civ))
data.add(DataPoint(1,-1160, civ))
data.add(DataPoint(2,-280, civ))
val result = lineChart.generateLabels(data, true)
Assert.assertTrue(result.size == 10 && result.first() == -1200 && result[1] == -1080 && result.last() == -120)
}
@Test
fun `chart is within the positive values near 0`() {
val data = mutableListOf <DataPoint<Int>>()
data.add(DataPoint(0,180, civ))
data.add(DataPoint(1,1101, civ))
data.add(DataPoint(2,980, civ))
val result = lineChart.generateLabels(data, true)
Assert.assertTrue(result.size == 11 && result.first() == 0 && result[1] == 120 && result.last() == 1200)
}
@Test
fun `chart is within the negative values near 0`() {
val data = mutableListOf <DataPoint<Int>>()
data.add(DataPoint(0,-180, civ))
data.add(DataPoint(1,-1101, civ))
data.add(DataPoint(2,-980, civ))
val result = lineChart.generateLabels(data, true)
Assert.assertTrue(result.size == 11 && result.first() == -1200 && result[1] == -1080 && result.last() == 0)
}
@Test
fun `chart is mostly in the positive values but not only`() {
val data = mutableListOf <DataPoint<Int>>()
data.add(DataPoint(0,210, civ))
data.add(DataPoint(1,1101, civ))
data.add(DataPoint(2,-89, civ))
val result = lineChart.generateLabels(data, true)
Assert.assertTrue(result.size == 12 && result.first() == -90 && result[1] == 0 && result.last() == 1200)
}
@Test
fun `chart is mostly in the negative values but not only`() {
val data = mutableListOf <DataPoint<Int>>()
data.add(DataPoint(0,111, civ))
data.add(DataPoint(1,-2101, civ))
data.add(DataPoint(2,-345, civ))
val result = lineChart.generateLabels(data, true)
Assert.assertTrue(result.size == 12 && result.first() == -2200 && result[10] == 0 && result.last() == 200)
}
}