From cc6fbaae8338840c52ed537ffda5f12a8869cdeb Mon Sep 17 00:00:00 2001 From: Kenneth Endfinger Date: Tue, 4 Jan 2022 02:18:54 -0500 Subject: [PATCH] Gjallarhorn: Heat Map Support --- .../foundation/gjallarhorn/BlockStateImage.kt | 77 +++++++++++++------ .../gjallarhorn/BlockStateTracker.kt | 9 ++- .../foundation/gjallarhorn/BlockTrackMode.kt | 6 ++ .../gjallarhorn/commands/BlockLogReplay.kt | 40 +++++++--- .../gjallarhorn/util/ColorGradient.kt | 65 ++++++++++++++++ .../foundation/gjallarhorn/util/FloatClamp.kt | 12 +++ .../foundation/gjallarhorn/util/ImageTools.kt | 7 ++ .../gjallarhorn/util/RandomColorKey.kt | 2 +- .../queries/player_positions_aggregates.sql | 64 +++++++++++++++ 9 files changed, 245 insertions(+), 37 deletions(-) create mode 100644 foundation-gjallarhorn/src/main/kotlin/cloud/kubelet/foundation/gjallarhorn/BlockTrackMode.kt create mode 100644 foundation-gjallarhorn/src/main/kotlin/cloud/kubelet/foundation/gjallarhorn/util/ColorGradient.kt create mode 100644 foundation-gjallarhorn/src/main/kotlin/cloud/kubelet/foundation/gjallarhorn/util/FloatClamp.kt create mode 100644 foundation-gjallarhorn/src/main/kotlin/cloud/kubelet/foundation/gjallarhorn/util/ImageTools.kt create mode 100644 foundation-gjallarhorn/src/main/resources/queries/player_positions_aggregates.sql diff --git a/foundation-gjallarhorn/src/main/kotlin/cloud/kubelet/foundation/gjallarhorn/BlockStateImage.kt b/foundation-gjallarhorn/src/main/kotlin/cloud/kubelet/foundation/gjallarhorn/BlockStateImage.kt index c71a1cf..bf22282 100644 --- a/foundation-gjallarhorn/src/main/kotlin/cloud/kubelet/foundation/gjallarhorn/BlockStateImage.kt +++ b/foundation-gjallarhorn/src/main/kotlin/cloud/kubelet/foundation/gjallarhorn/BlockStateImage.kt @@ -1,12 +1,14 @@ package cloud.kubelet.foundation.gjallarhorn +import cloud.kubelet.foundation.gjallarhorn.util.ColorGradient +import cloud.kubelet.foundation.gjallarhorn.util.FloatClamp import cloud.kubelet.foundation.gjallarhorn.util.RandomColorKey import java.awt.Color import java.awt.image.BufferedImage import java.util.* class BlockStateImage { - val blocks = TreeMap>>() + private val blocks = TreeMap>>() fun put(position: BlockPosition, state: BlockState) { blocks.getOrPut(position.x) { @@ -16,34 +18,61 @@ class BlockStateImage { }[position.y] = state } - fun buildBufferedImage(): BufferedImage { + fun buildTopDownImage(): BufferedImage { val colorKey = RandomColorKey() - val xMax = blocks.keys.maxOf { it } - val zMax = blocks.maxOf { it.value.maxOf { it.key } } + return buildPixelQuadImage { x, z -> + val maybeYBlocks = blocks[x]?.get(z) + if (maybeYBlocks == null) { + setPixelQuad(x, z, Color.white.rgb) + return@buildPixelQuadImage + } + val maxBlockState = maybeYBlocks.maxByOrNull { it.key }?.value + if (maxBlockState == null) { + setPixelQuad(x, z, Color.white.rgb) + return@buildPixelQuadImage + } + val color = colorKey.map(maxBlockState.type) + setPixelQuad(x, z, color.rgb) + } + } + + fun buildHeightMapImage(): BufferedImage { + val yMin = blocks.minOf { xSection -> xSection.value.minOf { zSection -> zSection.value.minOf { it.key } } } + val yMax = blocks.maxOf { xSection -> xSection.value.maxOf { zSection -> zSection.value.maxOf { it.key } } } + val clamp = FloatClamp(yMin, yMax) + + return buildHeatMapImage(clamp) { x, z -> blocks[x]?.get(z)?.maxOf { it.key } } + } + + fun buildHeatMapImage(clamp: FloatClamp, calculate: (Long, Long) -> Long?): BufferedImage = + buildPixelQuadImage { x, z -> + val value = calculate(x, z) + val color = if (value != null) { + val floatValue = clamp.convert(value) + ColorGradient.HeatMap.getColorAtValue(floatValue) + } else { + Color.white + } + + setPixelQuad(x, z, color.rgb) + } + + private fun BufferedImage.setPixelQuad(x: Long, z: Long, rgb: Int) { + setRGB(x.toInt() * 2, z.toInt() * 2, rgb) + setRGB((x.toInt() * 2) + 1, z.toInt() * 2, rgb) + setRGB(x.toInt() * 2, (z.toInt() * 2) + 1, rgb) + setRGB((x.toInt() * 2) + 1, (z.toInt() * 2) + 1, rgb) + } + + private fun buildPixelQuadImage(callback: BufferedImage.(Long, Long) -> Unit): BufferedImage { + val xMax = blocks.keys.maxOf { it } + val zMax = blocks.maxOf { xSection -> xSection.value.maxOf { zSection -> zSection.key } } val bufferedImage = BufferedImage(xMax.toInt() * 2, zMax.toInt() * 2, BufferedImage.TYPE_4BYTE_ABGR) + for (x in 0 until xMax) { for (z in 0 until zMax) { - fun set(rgb: Int) { - bufferedImage.setRGB(x.toInt() * 2, z.toInt() * 2, rgb) - bufferedImage.setRGB((x.toInt() * 2) + 1, z.toInt() * 2, rgb) - bufferedImage.setRGB(x.toInt() * 2, (z.toInt() * 2) + 1, rgb) - bufferedImage.setRGB((x.toInt() * 2) + 1, (z.toInt() * 2) + 1, rgb) - } - - val maybeYBlocks = blocks[x]?.get(z) - if (maybeYBlocks == null) { - set(Color.white.rgb) - continue - } - val maxBlockState = maybeYBlocks.maxByOrNull { it.key }?.value - if (maxBlockState == null) { - set(Color.white.rgb) - continue - } - - val color = colorKey.map(maxBlockState.type) - set(color.rgb) + callback(bufferedImage, x, z) } } return bufferedImage diff --git a/foundation-gjallarhorn/src/main/kotlin/cloud/kubelet/foundation/gjallarhorn/BlockStateTracker.kt b/foundation-gjallarhorn/src/main/kotlin/cloud/kubelet/foundation/gjallarhorn/BlockStateTracker.kt index 61b1094..9bccb59 100644 --- a/foundation-gjallarhorn/src/main/kotlin/cloud/kubelet/foundation/gjallarhorn/BlockStateTracker.kt +++ b/foundation-gjallarhorn/src/main/kotlin/cloud/kubelet/foundation/gjallarhorn/BlockStateTracker.kt @@ -1,9 +1,8 @@ package cloud.kubelet.foundation.gjallarhorn -import kotlin.collections.HashMap import kotlin.math.absoluteValue -class BlockStateTracker { +class BlockStateTracker(private val mode: BlockTrackMode = BlockTrackMode.RemoveOnDelete) { val blocks = HashMap() fun place(position: BlockPosition, state: BlockState) { @@ -11,7 +10,11 @@ class BlockStateTracker { } fun delete(position: BlockPosition) { - blocks.remove(position) + if (mode == BlockTrackMode.AirOnDelete) { + blocks[position] = BlockState("minecraft:air") + } else { + blocks.remove(position) + } } fun calculateZeroBlockOffset(): BlockOffset { diff --git a/foundation-gjallarhorn/src/main/kotlin/cloud/kubelet/foundation/gjallarhorn/BlockTrackMode.kt b/foundation-gjallarhorn/src/main/kotlin/cloud/kubelet/foundation/gjallarhorn/BlockTrackMode.kt new file mode 100644 index 0000000..2ddd48f --- /dev/null +++ b/foundation-gjallarhorn/src/main/kotlin/cloud/kubelet/foundation/gjallarhorn/BlockTrackMode.kt @@ -0,0 +1,6 @@ +package cloud.kubelet.foundation.gjallarhorn + +enum class BlockTrackMode { + RemoveOnDelete, + AirOnDelete +} diff --git a/foundation-gjallarhorn/src/main/kotlin/cloud/kubelet/foundation/gjallarhorn/commands/BlockLogReplay.kt b/foundation-gjallarhorn/src/main/kotlin/cloud/kubelet/foundation/gjallarhorn/commands/BlockLogReplay.kt index c5f90d2..a3a1adc 100644 --- a/foundation-gjallarhorn/src/main/kotlin/cloud/kubelet/foundation/gjallarhorn/commands/BlockLogReplay.kt +++ b/foundation-gjallarhorn/src/main/kotlin/cloud/kubelet/foundation/gjallarhorn/commands/BlockLogReplay.kt @@ -1,6 +1,7 @@ package cloud.kubelet.foundation.gjallarhorn.commands import cloud.kubelet.foundation.gjallarhorn.* +import cloud.kubelet.foundation.gjallarhorn.util.savePngFile import cloud.kubelet.foundation.heimdall.view.BlockChangeView import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.core.requireObject @@ -11,22 +12,26 @@ import org.jetbrains.exposed.sql.SqlExpressionBuilder.lessEq import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.transactions.transaction -import java.io.File import java.time.Instant -import javax.imageio.ImageIO +import java.util.concurrent.atomic.AtomicLong class BlockLogReplay : CliktCommand("Replay Block Logs", name = "replay-block-log") { private val db by requireObject() private val timeAsString by option("--time", help = "Replay Time") - private val render by option("--render", help = "Enable Render Mode").flag() + private val renderTopDown by option("--render-top-down", help = "Render TOp Down Image").flag() + private val renderHeightMap by option("--render-height-map", help = "Render Height Map Image").flag() + + private val considerAirBlocks by option("--consider-air-blocks", help = "Enable Air Block Consideration").flag() override fun run() { val filter = compose( combine = { a, b -> a and b }, { timeAsString != null } to { BlockChangeView.time lessEq Instant.parse(timeAsString) } ) - val tracker = BlockStateTracker() + val tracker = + BlockStateTracker(if (considerAirBlocks) BlockTrackMode.AirOnDelete else BlockTrackMode.RemoveOnDelete) + val blockChangeCounter = AtomicLong() transaction(db) { BlockChangeView.select(filter).orderBy(BlockChangeView.time).forEach { row -> val changeIsBreak = row[BlockChangeView.isBreak] @@ -41,14 +46,31 @@ class BlockLogReplay : CliktCommand("Replay Block Logs", name = "replay-block-lo } else { tracker.place(location, BlockState(block)) } + + val count = blockChangeCounter.addAndGet(1) + if (count % 1000L == 0L) { + System.err.println("Calculating Block Changes... $count") + } } } + System.err.println("Total Block Changes... ${blockChangeCounter.get()}") - if (render) { + val uniqueBlockPositions = tracker.blocks.size + System.err.println("Unique Block Positions... $uniqueBlockPositions") + + val blockZeroOffset = tracker.calculateZeroBlockOffset() + System.err.println("Zero Block Offset... $blockZeroOffset") + + if (renderTopDown) { val image = BlockStateImage() - tracker.populate(image, offset = tracker.calculateZeroBlockOffset()) - val bufferedImage = image.buildBufferedImage() - ImageIO.write(bufferedImage, "png", File("top-down.png")) + tracker.populate(image, offset = blockZeroOffset) + val bufferedImage = image.buildTopDownImage() + bufferedImage.savePngFile("top-down.png") + } else if (renderHeightMap) { + val image = BlockStateImage() + tracker.populate(image, offset = blockZeroOffset) + val bufferedImage = image.buildHeightMapImage() + bufferedImage.savePngFile("height-map.png") } else { println("x,y,z,block") for ((position, block) in tracker.blocks) { @@ -56,4 +78,4 @@ class BlockLogReplay : CliktCommand("Replay Block Logs", name = "replay-block-lo } } } -} \ No newline at end of file +} diff --git a/foundation-gjallarhorn/src/main/kotlin/cloud/kubelet/foundation/gjallarhorn/util/ColorGradient.kt b/foundation-gjallarhorn/src/main/kotlin/cloud/kubelet/foundation/gjallarhorn/util/ColorGradient.kt new file mode 100644 index 0000000..11535f8 --- /dev/null +++ b/foundation-gjallarhorn/src/main/kotlin/cloud/kubelet/foundation/gjallarhorn/util/ColorGradient.kt @@ -0,0 +1,65 @@ +package cloud.kubelet.foundation.gjallarhorn.util + +import java.awt.Color +import kotlin.math.max + +class ColorGradient { + data class ColorPoint( + val r: Float, + val g: Float, + val b: Float, + val value: Float + ) { + fun toColor() = Color( + FloatClamp.ColorRgbComponent.convert(r).toInt(), + FloatClamp.ColorRgbComponent.convert(g).toInt(), + FloatClamp.ColorRgbComponent.convert(b).toInt() + ) + } + + private val points = mutableListOf() + + fun addColorPoint(red: Float, green: Float, blue: Float, value: Float) { + val point = ColorPoint(red, green, blue, value) + for (x in 0 until points.size) { + if (value < points[x].value) { + points.add(x, point) + return + } + } + points.add(point) + } + + fun getColorAtValue(value: Float): Color { + if (points.isEmpty()) { + return ColorPoint(0f, 0f, 0f, value).toColor() + } + + for (x in 0 until points.size) { + val current = points[x] + if (value < current.value) { + val previous = points[max(0, x - 1)] + val diff = previous.value - current.value + val fractionBetween = if (diff == 0f) 0f else (value - current.value) / diff + return ColorPoint( + (previous.r - current.r) * fractionBetween + current.r, + (previous.g - current.g) * fractionBetween + current.g, + (previous.b - current.b) * fractionBetween + current.b, + value + ).toColor() + } + } + + return points.last().copy(value = value).toColor() + } + + companion object { + val HeatMap = ColorGradient().apply { + addColorPoint(0f, 0f, 1f, 0.0f) + addColorPoint(0f, 1f, 1f, 0.25f) + addColorPoint(0f, 1f, 0f, 0.5f) + addColorPoint(1f, 1f, 0f, 0.75f) + addColorPoint(1f, 0f, 0f, 1.0f) + } + } +} diff --git a/foundation-gjallarhorn/src/main/kotlin/cloud/kubelet/foundation/gjallarhorn/util/FloatClamp.kt b/foundation-gjallarhorn/src/main/kotlin/cloud/kubelet/foundation/gjallarhorn/util/FloatClamp.kt new file mode 100644 index 0000000..615d1d1 --- /dev/null +++ b/foundation-gjallarhorn/src/main/kotlin/cloud/kubelet/foundation/gjallarhorn/util/FloatClamp.kt @@ -0,0 +1,12 @@ +package cloud.kubelet.foundation.gjallarhorn.util + +import kotlin.math.roundToLong + +class FloatClamp(val min: Long, val max: Long) { + fun convert(value: Float): Long = (value * max.toFloat()).roundToLong() + min + fun convert(value: Long): Float = (value - min.toFloat()) / max + + companion object { + val ColorRgbComponent = FloatClamp(0, 255) + } +} diff --git a/foundation-gjallarhorn/src/main/kotlin/cloud/kubelet/foundation/gjallarhorn/util/ImageTools.kt b/foundation-gjallarhorn/src/main/kotlin/cloud/kubelet/foundation/gjallarhorn/util/ImageTools.kt new file mode 100644 index 0000000..e3f6fb9 --- /dev/null +++ b/foundation-gjallarhorn/src/main/kotlin/cloud/kubelet/foundation/gjallarhorn/util/ImageTools.kt @@ -0,0 +1,7 @@ +package cloud.kubelet.foundation.gjallarhorn.util + +import java.awt.image.BufferedImage +import java.io.File +import javax.imageio.ImageIO + +fun BufferedImage.savePngFile(path: String) = ImageIO.write(this, "png", File(path)) diff --git a/foundation-gjallarhorn/src/main/kotlin/cloud/kubelet/foundation/gjallarhorn/util/RandomColorKey.kt b/foundation-gjallarhorn/src/main/kotlin/cloud/kubelet/foundation/gjallarhorn/util/RandomColorKey.kt index f1b086a..3e0dd03 100644 --- a/foundation-gjallarhorn/src/main/kotlin/cloud/kubelet/foundation/gjallarhorn/util/RandomColorKey.kt +++ b/foundation-gjallarhorn/src/main/kotlin/cloud/kubelet/foundation/gjallarhorn/util/RandomColorKey.kt @@ -16,4 +16,4 @@ class RandomColorKey { } private fun randomColor() = Color((Math.random() * 0x1000000).toInt()) -} \ No newline at end of file +} diff --git a/foundation-gjallarhorn/src/main/resources/queries/player_positions_aggregates.sql b/foundation-gjallarhorn/src/main/resources/queries/player_positions_aggregates.sql new file mode 100644 index 0000000..45c9781 --- /dev/null +++ b/foundation-gjallarhorn/src/main/resources/queries/player_positions_aggregates.sql @@ -0,0 +1,64 @@ +WITH + unique_player_ids AS ( + SELECT + DISTINCT player + FROM heimdall.player_sessions + ), + player_names AS ( + SELECT + player, + ( + SELECT name + FROM heimdall.player_sessions + WHERE player = unique_player_ids.player + ORDER BY "end" DESC + LIMIT 1 + ) AS name + FROM unique_player_ids + ), + unique_world_ids AS ( + SELECT + DISTINCT to_world AS world + FROM heimdall.world_changes + ), + world_names AS ( + SELECT + world, + ( + SELECT to_world_name + FROM heimdall.world_changes + WHERE world = heimdall.world_changes.to_world + ORDER BY time DESC + LIMIT 1 + ) AS name + FROM unique_world_ids + ), + player_calculated_positions AS ( + SELECT + player, + world, + AVG(x) AS avg_x, + AVG(y) AS avg_y, + AVG(z) AS avg_z, + MAX(x) AS max_x, + MAX(y) AS max_y, + MAX(z) AS max_z, + MIN(x) AS min_x, + MIN(y) AS min_y, + MIN(z) AS min_z, + COUNT(*) AS count, + MODE() WITHIN GROUP (ORDER BY x) AS mode_x, + MODE() WITHIN GROUP (ORDER BY y) AS mode_y, + MODE() WITHIN GROUP (ORDER BY z) AS mode_z + FROM heimdall.player_positions + GROUP BY player, world + ) +SELECT + player_names.name AS player_name, + world_names.name AS world_name, + player_calculated_positions.* +FROM player_calculated_positions +JOIN player_names + ON player_names.player = player_calculated_positions.player +JOIN world_names + ON world_names.world = player_calculated_positions.world