From a63ade715bb12259172d1c336fee91cb0aa43121 Mon Sep 17 00:00:00 2001 From: Albin Dittli Date: Wed, 25 Mar 2026 16:02:09 -0600 Subject: [PATCH] add custom labels and change how graphs handle padding --- .../lightningkite/mppexampleapp/HomePage.kt | 52 +----- .../mppexampleapp/docs/CheatSheet.kt | 24 +++ .../lightningkite/kiteui/views/l2/Graph.kt | 158 +++++++++++------- 3 files changed, 122 insertions(+), 112 deletions(-) diff --git a/example-app/src/commonMain/kotlin/com/lightningkite/mppexampleapp/HomePage.kt b/example-app/src/commonMain/kotlin/com/lightningkite/mppexampleapp/HomePage.kt index d8a591940..0d1ca455c 100644 --- a/example-app/src/commonMain/kotlin/com/lightningkite/mppexampleapp/HomePage.kt +++ b/example-app/src/commonMain/kotlin/com/lightningkite/mppexampleapp/HomePage.kt @@ -1,65 +1,21 @@ package com.lightningkite.mppexampleapp import com.lightningkite.kiteui.Build -import com.lightningkite.kiteui.HttpMethod import com.lightningkite.kiteui.Routable -import com.lightningkite.kiteui.WeakReference -import com.lightningkite.kiteui.afterTimeout -import com.lightningkite.kiteui.checkLeakAfterDelay -import com.lightningkite.kiteui.fetch -import com.lightningkite.kiteui.leaks -import com.lightningkite.kiteui.models.DialogSemantic -import com.lightningkite.kiteui.models.Dimension import com.lightningkite.kiteui.models.Icon -import com.lightningkite.kiteui.models.ScreenTransitions -import com.lightningkite.kiteui.models.VideoRaw -import com.lightningkite.kiteui.models.VideoRemote -import com.lightningkite.kiteui.models.rem import com.lightningkite.kiteui.navigation.Page -import com.lightningkite.kiteui.reactive.* import com.lightningkite.kiteui.reactive.Action -import com.lightningkite.kiteui.requestFile -import com.lightningkite.kiteui.views.RView import com.lightningkite.kiteui.views.ViewWriter -import com.lightningkite.kiteui.views.animateIn -import com.lightningkite.kiteui.views.animateOut -import com.lightningkite.kiteui.views.card import com.lightningkite.kiteui.views.centered -import com.lightningkite.kiteui.views.direct.RawVideoView -import com.lightningkite.kiteui.views.direct.RowOrCol -import com.lightningkite.kiteui.views.direct.button -import com.lightningkite.kiteui.views.direct.col -import com.lightningkite.kiteui.views.direct.coordinatorDragHandle -import com.lightningkite.kiteui.views.direct.dismissBackground -import com.lightningkite.kiteui.views.direct.h1 -import com.lightningkite.kiteui.views.direct.h2 -import com.lightningkite.kiteui.views.direct.media -import com.lightningkite.kiteui.views.direct.onClick -import com.lightningkite.kiteui.views.direct.row -import com.lightningkite.kiteui.views.direct.separator -import com.lightningkite.kiteui.views.direct.shownWhen -import com.lightningkite.kiteui.views.direct.sizeConstraints -import com.lightningkite.kiteui.views.direct.space -import com.lightningkite.kiteui.views.direct.text -import com.lightningkite.kiteui.views.direct.video +import com.lightningkite.kiteui.views.direct.* import com.lightningkite.kiteui.views.expanding import com.lightningkite.kiteui.views.important -import com.lightningkite.kiteui.views.l2.applySafeInsets -import com.lightningkite.kiteui.views.l2.coordinatorFrame -import com.lightningkite.kiteui.views.l2.dialog -import com.lightningkite.kiteui.views.overlayWriter -import com.lightningkite.kiteui.views.withoutAnimation import com.lightningkite.mppexampleapp.docs.article import com.lightningkite.mppexampleapp.docs.example -import com.lightningkite.reactive.context.* -import com.lightningkite.reactive.core.* -import com.lightningkite.reactive.extensions.* -import com.lightningkite.reactive.lensing.* -import com.lightningkite.readable.* -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import com.lightningkite.reactive.core.Constant +import com.lightningkite.reactive.core.Reactive +import com.lightningkite.reactive.core.Signal import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds @Routable("/") class HomePage : Page { diff --git a/example-app/src/commonMain/kotlin/com/lightningkite/mppexampleapp/docs/CheatSheet.kt b/example-app/src/commonMain/kotlin/com/lightningkite/mppexampleapp/docs/CheatSheet.kt index 8b0f4b398..c6db1ce33 100644 --- a/example-app/src/commonMain/kotlin/com/lightningkite/mppexampleapp/docs/CheatSheet.kt +++ b/example-app/src/commonMain/kotlin/com/lightningkite/mppexampleapp/docs/CheatSheet.kt @@ -362,6 +362,30 @@ object CheatSheet : DocPage { h6("Header 6") } ) + example( + name = "graph", + description = "Display a graph", + code = """ + graph { + ::data { listOf(Point(0.0, 0.0), Point(1.0, 100.0), Point(2.0, 350.0), Point(3.0, 200.0), Point(4.0, 500.0), Point(5.0, 750.0), Point(6.0, 700.0)) } + ::xAxisLabels { listOf("Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat") } + padding = 1.rem + pointShape = GraphDelegate.PointShape.Circle + xAxisLabel = "Past Week" + yAxisLabel = "Value" + } + """.trimIndent(), + result = { + graph { + ::data { listOf(Point(0.0, 0.0), Point(1.0, 100.0), Point(2.0, 350.0), Point(3.0, 200.0), Point(4.0, 500.0), Point(5.0, 750.0), Point(6.0, 700.0)) } + ::xAxisLabels { listOf("Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat") } + padding = 1.rem + pointShape = GraphDelegate.PointShape.Circle + xAxisLabel = "Past Week" + yAxisLabel = "Value" + } + } + ) } titledSection("Interactive Elements") { example( diff --git a/library/src/commonMain/kotlin/com/lightningkite/kiteui/views/l2/Graph.kt b/library/src/commonMain/kotlin/com/lightningkite/kiteui/views/l2/Graph.kt index ae4bcff89..d1f76e7a2 100644 --- a/library/src/commonMain/kotlin/com/lightningkite/kiteui/views/l2/Graph.kt +++ b/library/src/commonMain/kotlin/com/lightningkite/kiteui/views/l2/Graph.kt @@ -1,11 +1,13 @@ package com.lightningkite.kiteui.views.l2 +import com.lightningkite.kiteui.debugMode import com.lightningkite.kiteui.models.* import com.lightningkite.kiteui.views.ViewWriter import com.lightningkite.kiteui.views.canvas.DrawingContext2D import com.lightningkite.kiteui.views.canvas.TextAlign import com.lightningkite.kiteui.views.canvas.clear import com.lightningkite.kiteui.views.canvas.drawText +import com.lightningkite.kiteui.views.canvas.ellipse import com.lightningkite.kiteui.views.canvas.fill import com.lightningkite.kiteui.views.canvas.fillPaint import com.lightningkite.kiteui.views.canvas.font @@ -31,6 +33,11 @@ class GraphDelegate : CanvasDelegate() { // Data to be displayed on the graph var data: List = emptyList() + enum class PointShape { + Square, + Circle, + } + // Graph appearance properties private var _lineColor: Color? = null // Color.blue var lineColor: Color @@ -58,6 +65,10 @@ class GraphDelegate : CanvasDelegate() { var pointSize: Dimension get() = _pointSize ?: theme.padding.left set(value) { _pointSize = value } + private var _pointShape: PointShape? = null // PointShape.Square + var pointShape: PointShape + get() = _pointShape ?: PointShape.Square + set(value) { _pointShape = value } private var _lineWidth: Dimension? = null // 2.0.dp var lineWidth: Dimension get() = _lineWidth ?: 1.dp @@ -71,11 +82,45 @@ class GraphDelegate : CanvasDelegate() { var xAxisLabel: String = "X" var yAxisLabel: String = "Y" + var xAxisLabels: List? = null + var yAxisLabels: List? = null + // Font sizes var axisLabelFontSize: Dimension = 1.rem var tickLabelFontSize: Dimension = 0.8.rem var noDataMessageFontSize: Dimension = 2.rem + // Calculate data bounds + private val rawMinX get() = min(0.0, data.minOfOrNull { it.x } ?: 0.0) + private val rawMaxX get() = max(0.0, data.maxOfOrNull { it.x } ?: 0.0) + private val rawMinY get() = min(0.0, data.minOfOrNull { it.y } ?: 0.0) + private val rawMaxY get() = max(0.0, data.maxOfOrNull { it.y } ?: 0.0) + + // Add some padding to the bounds + private val rawRangeX get() = (rawMaxX - rawMinX).coerceAtLeast(1.0) + private val rawRangeY get() = (rawMaxY - rawMinY).coerceAtLeast(1.0) + private val minX get() = rawMinX - rawRangeX * 0.05 + private val maxX get() = rawMaxX + rawRangeX * 0.05 + private val minY get() = rawMinY - rawRangeY * 0.05 + private val maxY get() = rawMaxY + rawRangeY * 0.05 + private val xStep get() = xAxisLabels?.let { rawRangeX / (it.size - 1) } ?: calculateGridStep(maxX - minX) + private val yStep get() = yAxisLabels?.let { rawRangeY / (it.size - 1) } ?: calculateGridStep(maxY - minY) + + private val xAxisLabelHeight = 2.5.rem.canvasUnits + private val yAxisLabelWidth: Double + get() = (2 + (yAxisLabels?.maxOf { it.length } ?: run { + var longestLabelSize = 0 + var y = ceil(minY / yStep) * yStep + while (y <= maxY) { + val nextLabelSize = formatNumber(y).length + if (nextLabelSize > longestLabelSize) { + longestLabelSize = nextLabelSize + } + y += yStep + } + longestLabelSize + }) / 2.0).rem.canvasUnits + override fun draw(context: DrawingContext2D) { if (data.isEmpty()) { drawEmptyGraph(context) @@ -85,44 +130,46 @@ class GraphDelegate : CanvasDelegate() { val width = context.width val height = context.height - // Calculate data bounds - val minX = data.minOfOrNull { it.x } ?: 0.0 - val maxX = data.maxOfOrNull { it.x } ?: 0.0 - val minY = data.minOfOrNull { it.y } ?: 0.0 - val maxY = data.maxOfOrNull { it.y } ?: 0.0 - - // Add some padding to the bounds - val rangeX = (maxX - minX).coerceAtLeast(1.0) - val rangeY = (maxY - minY).coerceAtLeast(1.0) - val paddedMinX = minX - rangeX * 0.05 - val paddedMaxX = maxX + rangeX * 0.05 - val paddedMinY = minY - rangeY * 0.05 - val paddedMaxY = maxY + rangeY * 0.05 - // Get padding in canvas units val paddingCanvas = padding.canvasUnits // Scale factors to convert data coordinates to canvas coordinates - val scaleX = (width - paddingCanvas * 2) / (paddedMaxX - paddedMinX) - val scaleY = (height - paddingCanvas * 2) / (paddedMaxY - paddedMinY) + val scaleX = (width - paddingCanvas * 2 - yAxisLabelWidth) / (maxX - minX) + val scaleY = (height - paddingCanvas * 2 - xAxisLabelHeight) / (maxY - minY) // Function to convert data X to canvas X - val toCanvasX = { x: Double -> (x - paddedMinX) * scaleX + paddingCanvas } + val toCanvasX = { x: Double -> (x - minX) * scaleX + paddingCanvas + yAxisLabelWidth } // Function to convert data Y to canvas Y (note the inversion for Y) - val toCanvasY = { y: Double -> height - ((y - paddedMinY) * scaleY + paddingCanvas) } + val toCanvasY = { y: Double -> height - ((y - minY) * scaleY + paddingCanvas + xAxisLabelHeight) } with(context) { // Clear the canvas clear() + // draw debug (Padding and Label Blocks) + if (debugMode) { + fillPaint = Color.fromHexString("#808050") + beginPath() + rect(0.0, 0.0, paddingCanvas, height) + rect(0.0, 0.0, width, paddingCanvas) + rect(width - paddingCanvas, 0.0, paddingCanvas, height) + rect(0.0, height - paddingCanvas, width, paddingCanvas) + fill() + fillPaint = Color.fromHexString("#65548a") + beginPath() + rect(paddingCanvas, paddingCanvas, yAxisLabelWidth, height - 2 * paddingCanvas) + rect(paddingCanvas, height - paddingCanvas - xAxisLabelHeight, width - 2 * paddingCanvas, xAxisLabelHeight) + fill() + } + // Draw grid if enabled if (showGrid) { - drawGrid(context, paddedMinX, paddedMaxX, paddedMinY, paddedMaxY, toCanvasX, toCanvasY) + drawGrid(context, toCanvasX, toCanvasY) } // Draw axes - drawAxes(context, paddedMinX, paddedMaxX, paddedMinY, paddedMaxY, toCanvasX, toCanvasY) + drawAxes(context, toCanvasX, toCanvasY) // Draw data line strokePaint = lineColor @@ -145,7 +192,14 @@ class GraphDelegate : CanvasDelegate() { beginPath() val cx = toCanvasX(point.x) val cy = toCanvasY(point.y) - rect(cx - pointSizeCanvas / 2, cy - pointSizeCanvas / 2, pointSizeCanvas, pointSizeCanvas) + when (pointShape) { + PointShape.Square -> { + rect(cx - pointSizeCanvas / 2, cy - pointSizeCanvas / 2, pointSizeCanvas, pointSizeCanvas) + } + PointShape.Circle -> { + ellipse(cx, cy, pointSizeCanvas / 2, pointSizeCanvas / 2, 0.0, 0.0, 2*PI) + } + } fill() } } @@ -193,35 +247,19 @@ class GraphDelegate : CanvasDelegate() { private fun drawGrid( context: DrawingContext2D, - minX: Double, - maxX: Double, - minY: Double, - maxY: Double, toCanvasX: (Double) -> Double, toCanvasY: (Double) -> Double ) { - val width = context.width - val height = context.height - with(context) { strokePaint = gridColor lineWidth = 0.5 * this@GraphDelegate.lineWidth.canvasUnits - val paddingCanvas = padding.canvasUnits - - // Calculate grid line spacing - val rangeX = maxX - minX - val rangeY = maxY - minY - - val xStep = calculateGridStep(rangeX) - val yStep = calculateGridStep(rangeY) - // Draw vertical grid lines var x = ceil(minX / xStep) * xStep while (x <= maxX) { beginPath() - moveTo(toCanvasX(x), paddingCanvas) - lineTo(toCanvasX(x), height - paddingCanvas) + moveTo(toCanvasX(x), toCanvasY(minY)) + lineTo(toCanvasX(x), toCanvasY(maxY)) stroke() x += xStep } @@ -230,8 +268,8 @@ class GraphDelegate : CanvasDelegate() { var y = ceil(minY / yStep) * yStep while (y <= maxY) { beginPath() - moveTo(paddingCanvas, toCanvasY(y)) - lineTo(width - paddingCanvas, toCanvasY(y)) + moveTo(toCanvasX(minX), toCanvasY(y)) + lineTo(toCanvasX(maxX), toCanvasY(y)) stroke() y += yStep } @@ -240,10 +278,6 @@ class GraphDelegate : CanvasDelegate() { private fun drawAxes( context: DrawingContext2D, - minX: Double, - maxX: Double, - minY: Double, - maxY: Double, toCanvasX: (Double) -> Double, toCanvasY: (Double) -> Double ) { @@ -258,27 +292,22 @@ class GraphDelegate : CanvasDelegate() { // X-axis beginPath() - moveTo(paddingCanvas, toCanvasY(0.0).coerceIn(paddingCanvas, height - paddingCanvas)) - lineTo(width - paddingCanvas, toCanvasY(0.0).coerceIn(paddingCanvas, height - paddingCanvas)) + println("minX: $minX") + println("maxX: $maxX") + moveTo(toCanvasX(minX), toCanvasY(0.0)) + lineTo(toCanvasX(maxX), toCanvasY(0.0)) stroke() // Y-axis beginPath() - moveTo(toCanvasX(0.0).coerceIn(paddingCanvas, width - paddingCanvas), paddingCanvas) - lineTo(toCanvasX(0.0).coerceIn(paddingCanvas, width - paddingCanvas), height - paddingCanvas) + moveTo(toCanvasX(0.0), toCanvasY(minY)) + lineTo(toCanvasX(0.0), toCanvasY(maxY)) stroke() // Draw tick marks and labels fillPaint = axisColor font(tickLabelFontSize.canvasUnits, FontAndStyle(systemDefaultFont)) - // Calculate tick spacing - val rangeX = maxX - minX - val rangeY = maxY - minY - - val xStep = calculateGridStep(rangeX) - val yStep = calculateGridStep(rangeY) - // X-axis ticks and labels var x = ceil(minX / xStep) * xStep while (x <= maxX) { @@ -286,13 +315,13 @@ class GraphDelegate : CanvasDelegate() { // Draw tick beginPath() - moveTo(cx, toCanvasY(0.0).coerceIn(paddingCanvas, height - paddingCanvas)) - lineTo(cx, toCanvasY(0.0).coerceIn(paddingCanvas, height - paddingCanvas) + 5) + moveTo(cx, toCanvasY(0.0)) + lineTo(cx, toCanvasY(0.0) + (tickLabelFontSize / 3).canvasUnits) stroke() // Draw label textAlign(TextAlign.center) - drawText(formatNumber(x), cx, height - paddingCanvas + 15) + drawText(xAxisLabels?.let { it[(x / xStep).roundToInt()] } ?: formatNumber(x), cx, height - paddingCanvas - xAxisLabelHeight + 1.rem.canvasUnits) x += xStep } @@ -304,13 +333,13 @@ class GraphDelegate : CanvasDelegate() { // Draw tick beginPath() - moveTo(toCanvasX(0.0).coerceIn(paddingCanvas, width - paddingCanvas), cy) - lineTo(toCanvasX(0.0).coerceIn(paddingCanvas, width - paddingCanvas) - 5, cy) + moveTo(toCanvasX(0.0), cy) + lineTo(toCanvasX(0.0) - (tickLabelFontSize / 3).canvasUnits, cy) stroke() // Draw label textAlign(TextAlign.right) - drawText(formatNumber(y), paddingCanvas - 8, cy + 4) + drawText(yAxisLabels?.let { it[(y / yStep).roundToInt()] } ?: formatNumber(y), paddingCanvas + yAxisLabelWidth - 0.5.rem.canvasUnits, cy + 0.3.rem.canvasUnits) y += yStep } @@ -326,12 +355,13 @@ class GraphDelegate : CanvasDelegate() { // X-axis label textAlign(TextAlign.center) - drawText(xAxisLabel, width / 2, height - paddingCanvas / 3) + drawText(xAxisLabel, width / 2 + yAxisLabelWidth / 2, height - paddingCanvas - 0.15.rem.canvasUnits) // Y-axis label save() - translate(paddingCanvas / 3, height / 2) + translate(paddingCanvas + 1.rem.canvasUnits, height / 2 - xAxisLabelHeight / 2) rotate(-PI / 2) + textAlign(TextAlign.center) drawText(yAxisLabel, 0.0, 0.0) restore() }