KMPCharts/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/render/AxisRenderer.kt
Trentin Davide af6814e2b8 feat(chart): v0.5.0 architecture + interaction
Ships v0.5.0 via kmp-manager 6-phase flow. 13 plan tasks (D1, T1-T10, D3, D4),
14 agent calls, 6 P-groups. 107 → 207 tests on iosSimulatorArm64.

Toolchain (D1):
- Kotlin 2.1.0 → 2.3.21
- Compose-Multiplatform 1.8.0 → 1.11.0
- Android Gradle Plugin 8.7.3 → 9.2.0
- Gradle 8.11.1 → 9.5.1
- coroutines 1.9.0 → 1.11.0, kotlin-test → 2.3.21
- Drop kotlinx-datetime; use stdlib kotlin.time.Clock
- Drop iosX64 target (Compose-MP 1.11.0 has no ios_x64 variant)

Architecture (T1-T6, T10):
- TieredBuffer.snapshotWindow: bisect-based windowed snapshot
- New lod/ package: LodStrategy interface + MinMax/Lttb/MinMaxLttb impls
  (MinMaxLttb SOTA per arXiv 2305.00332, 1.80× faster than pure LTTB)
- New render/SignalRenderer: public interface + LineSignalRenderer object
- New render/AxisFormatter: 4 default impls (Time, Decimal, DateTime, Unit)
- HARD BREAK: deleted LodMode, LodDecimator, ChartConfig.targetFps
- ChartConfig split: DataConfig + AxisConfig + RenderConfig + FrameRate sealed
- @Immutable/@Stable on all public types (0 unstable)
- RealtimeChartState.clear() API

Interaction layer (T7):
- New interaction/ package
- ChartInteractionState + rememberChartInteractionState()
- ViewportMode sealed: Following / Frozen / History(anchorMs)
- Pinch zoom + drag pan + tap crosshair gestures
- Swipe-to-edge resumes Following
- InverseProjection: pixel → ms + bisect nearest-sample

Perf finishing (T8, T9, D3):
- LineSignalRenderer Stroke cache, AxisRenderer TextStyle cache
- resolveYRange Pair<Float,Float> → FloatArray out-param
- RealtimeChartState.signalsArray cached (invalidated on add/remove only)
- LTTB upper-bound aligned to half-open [start, start+windowMs) semantic

Correctness (D4):
- NumberFormat.formatFixed Long overflow guard @ |v|≥1e19

ABI baseline regenerated:
- chart-realtime.api: 161 → 428 LOC
- chart-realtime.klib.api: 211 → 501 LOC

Modules touched: chart-realtime (lib), app (consumer), gradle (toolchain),
.paul (state).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:05:43 +02:00

214 lines
9.4 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package dev.dtrentin.chart.render
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.sp
import dev.dtrentin.chart.model.AxisLabelMode
import dev.dtrentin.chart.model.ChartConfig
import dev.dtrentin.chart.model.ChartTheme
import dev.dtrentin.chart.model.YRange
import kotlin.math.ceil
import kotlin.math.floor
internal object AxisRenderer {
// ── T8: TextStyle cache ───────────────────────────────────────────────────
// Per-frame allocations of TextStyle (9.sp, theme.labelColor, optional TextAlign) are
// hoisted into a tiny inline cache keyed by (color.value, alignmentIndex). The cache is
// sized for the realistic max number of distinct styles in flight (one theme color × 3
// alignments). When a theme color changes (rare) the cache simply grows; we cap at
// STYLE_CACHE_MAX and clear-on-overflow.
//
// For label TextLayoutResult dedup we rely on Compose's internal `TextMeasurer.measure`
// LRU (it keys on (text, style, density, layoutDirection)). Hand-maintaining a second
// layout cache here would duplicate that work and risk staleness on density changes.
// See decision log in agent output.
//
// Threading: UI thread only (DrawScope.drawXAxis / drawYAxis are invoked from Canvas).
// No synchronization.
private val styleCache: HashMap<Long, TextStyle> = HashMap(4)
private const val STYLE_CACHE_MAX: Int = 16
// Encode (color, align) into a single Long key.
// - color.value is ULong → cast to Long (bit pattern preserved).
// - align: 0 = none, 1 = Center, 2 = End. Shifted into top 8 bits (unused by Color).
private fun styleKey(colorBits: Long, alignTag: Int): Long =
colorBits xor (alignTag.toLong() shl 56)
private fun textStyleFor(color: Color, align: TextAlign?): TextStyle {
val alignTag = when (align) {
null -> 0
TextAlign.Center -> 1
TextAlign.End -> 2
else -> 3
}
val key = styleKey(color.value.toLong(), alignTag)
val cached = styleCache[key]
if (cached != null) return cached
if (styleCache.size >= STYLE_CACHE_MAX) styleCache.clear()
val fresh = if (align == null) TextStyle(color = color, fontSize = 9.sp)
else TextStyle(color = color, fontSize = 9.sp, textAlign = align)
styleCache[key] = fresh
return fresh
}
fun DrawScope.drawXAxis(
windowStartMs: Long,
windowMs: Long,
theme: ChartTheme,
textMeasurer: TextMeasurer,
labelMode: AxisLabelMode,
chartLeft: Float,
chartBottom: Float,
t0Ms: Long,
showGrid: Boolean = true,
) {
val w = size.width
val chartW = w - chartLeft
if (windowMs <= 0 || chartW <= 0) return
val windowSec = windowMs / 1000.0
val tickInterval = NumberFormat.niceInterval(windowSec, targetTickCount = 6)
var tickSec = floor((windowStartMs / 1000.0) / tickInterval) * tickInterval
val windowEndSec = (windowStartMs + windowMs) / 1000.0
// Hoist styles for this draw call. textStyleFor() returns a cached instance.
val styleDefault = textStyleFor(theme.labelColor, align = null)
val styleCenter = textStyleFor(theme.labelColor, align = TextAlign.Center)
while (tickSec <= windowEndSec + tickInterval * 0.01) {
val fracX = ((tickSec - windowStartMs / 1000.0) / windowSec).toFloat()
val xPx = chartLeft + fracX * chartW
if (xPx in chartLeft..w) {
if (showGrid) drawLine(theme.gridColor, Offset(xPx, 0f), Offset(xPx, chartBottom), strokeWidth = 1f)
if (labelMode != AxisLabelMode.HIDDEN) {
val label = NumberFormat.formatTimeSec(tickSec - t0Ms / 1000.0)
when (labelMode) {
AxisLabelMode.INSIDE -> {
val availableW = w - xPx - 6f
if (availableW > 4f) {
drawText(
textMeasurer, label,
topLeft = Offset(xPx + 4f, chartBottom - 18f),
style = styleDefault,
size = Size(availableW, with(this) { 12.sp.toPx() }),
)
}
}
AxisLabelMode.BESIDE -> {
val labelH = size.height - chartBottom
if (labelH > 2f) {
val maxW = (chartW * tickInterval / windowSec).toFloat().coerceAtMost(chartW / 2f)
val labelX = xPx - maxW / 2f
if (labelX >= chartLeft) {
drawText(
textMeasurer, label,
topLeft = Offset(labelX, chartBottom + 6f),
style = styleCenter,
size = Size(maxW, labelH - 6f),
)
}
}
}
AxisLabelMode.HIDDEN -> Unit
}
}
}
tickSec += tickInterval
}
drawLine(theme.axisColor, Offset(chartLeft, chartBottom), Offset(w, chartBottom), strokeWidth = theme.strokeWidth)
}
fun DrawScope.drawYAxis(
yMin: Float,
yMax: Float,
theme: ChartTheme,
textMeasurer: TextMeasurer,
labelMode: AxisLabelMode,
labelDecimals: Int,
chartLeft: Float,
chartBottom: Float,
showGrid: Boolean = true,
) {
val w = size.width
val chartW = w - chartLeft
if (yMax <= yMin || chartW <= 0) return
val range = (yMax - yMin).toDouble()
val tickInterval = NumberFormat.niceInterval(range, targetTickCount = 5)
var tick = ceil(yMin / tickInterval) * tickInterval
// Hoist styles for this draw call. textStyleFor() returns a cached instance.
val styleDefault = textStyleFor(theme.labelColor, align = null)
val styleEnd = textStyleFor(theme.labelColor, align = TextAlign.End)
while (tick <= yMax + tickInterval * 0.01) {
val fracY = 1f - ((tick - yMin) / (yMax - yMin)).toFloat()
val yPx = fracY * chartBottom
if (yPx in 0f..chartBottom) {
if (showGrid) drawLine(theme.gridColor, Offset(chartLeft, yPx), Offset(w, yPx), strokeWidth = 1f)
if (labelMode != AxisLabelMode.HIDDEN) {
val label = NumberFormat.formatAxisValue(tick, labelDecimals)
when (labelMode) {
AxisLabelMode.INSIDE -> {
val labelY = (yPx - 10f).coerceAtMost(chartBottom - 14f)
if (labelY >= 0f && chartW > 4f) {
drawText(
textMeasurer, label,
topLeft = Offset(chartLeft + 6f, labelY),
style = styleDefault,
size = Size(chartW - 8f, with(this) { 12.sp.toPx() }),
)
}
}
AxisLabelMode.BESIDE -> {
if (chartLeft > 4f) {
val labelY = (yPx - 7f).coerceIn(0f, chartBottom - 14f)
drawText(
textMeasurer, label,
topLeft = Offset(2f, labelY),
style = styleEnd,
size = Size(chartLeft - 12f, with(this) { 12.sp.toPx() }),
)
}
}
AxisLabelMode.HIDDEN -> Unit
}
}
}
tick += tickInterval
}
drawLine(theme.axisColor, Offset(chartLeft, 0f), Offset(chartLeft, chartBottom), strokeWidth = theme.strokeWidth)
}
/**
* T9: zero-alloc Y-range resolution. Writes `outYRange[0] = yMin`, `outYRange[1] = yMax`.
* Caller owns the 2-element FloatArray (allocated once via `remember`).
*/
internal fun resolveYRange(
config: ChartConfig,
dataMin: Float,
dataMax: Float,
outYRange: FloatArray,
) {
when (val yr = config.data.yRange) {
is YRange.Fixed -> {
outYRange[0] = yr.min
outYRange[1] = yr.max
}
is YRange.Auto -> {
val range = (dataMax - dataMin).coerceAtLeast(1e-6f)
val pad = range * yr.paddingFraction
outYRange[0] = dataMin - pad
outYRange[1] = dataMax + pad
}
}
}
}