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>
214 lines
9.4 KiB
Kotlin
214 lines
9.4 KiB
Kotlin
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
|
||
}
|
||
}
|
||
}
|
||
|
||
}
|