KMPCharts/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChart.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

299 lines
13 KiB
Kotlin

package dev.dtrentin.chart
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import dev.dtrentin.chart.buffer.TieredBuffer
import dev.dtrentin.chart.interaction.ChartInteractionState
import dev.dtrentin.chart.interaction.CrosshairState
import dev.dtrentin.chart.interaction.InverseProjection
import dev.dtrentin.chart.interaction.SignalValueAt
import dev.dtrentin.chart.interaction.ViewportMode
import dev.dtrentin.chart.model.AxisLabelMode
import dev.dtrentin.chart.model.ChartTheme
import dev.dtrentin.chart.render.AxisRenderer.drawXAxis
import dev.dtrentin.chart.render.AxisRenderer.drawYAxis
import dev.dtrentin.chart.render.AxisRenderer.resolveYRange
/**
* Renders all signals held by [state] on a Canvas. Recomposition is driven by Compose
* snapshot observation of `state.dataVersion`, so the Canvas redraws only when new data
* arrives (batched per frame by the Compose snapshot system).
*
* v0.5.0 wiring:
* - Decimation strategy from `state.config.render.lodStrategy` (default MinMaxLTTB).
* - Per-signal renderer from `signal.renderer` (default LineSignalRenderer singleton).
* - Decimation runs OUTSIDE the renderer and produces pre-projected `(lodX, lodY, count)`
* passed by primitive to `SignalRenderer.drawSignal`.
* - Optional [interaction] enables pinch-zoom / drag-pan / tap-crosshair. When null,
* chart behaves identically to v0.4.0 (read-only).
*
* @param state holds all signal data and config.
* @param modifier applied to Canvas.
* @param xWindowSeconds visible X window in seconds; overrides `state.config.data.xWindowSeconds` at call site.
* @param theme visual theme; overrides `state.config.render.theme` at call site.
* @param interaction optional state holder enabling user gestures. Create via
* [dev.dtrentin.chart.interaction.rememberChartInteractionState].
*/
@Composable
public fun RealtimeChart(
state: RealtimeChartState,
modifier: Modifier = Modifier,
xWindowSeconds: Float = state.config.data.xWindowSeconds,
theme: ChartTheme = state.config.render.theme,
interaction: ChartInteractionState? = null,
) {
val config = state.config
val lodStrategy = config.render.lodStrategy
val textMeasurer = rememberTextMeasurer()
val density = LocalDensity.current
val chartLeftPx = remember(config.axis.yLabelMode) {
if (config.axis.yLabelMode == AxisLabelMode.BESIDE) with(density) { 52.dp.toPx() } else 0f
}
val chartBottomInsetPx = remember(config.axis.xLabelMode) {
if (config.axis.xLabelMode == AxisLabelMode.BESIDE) with(density) { 20.dp.toPx() } else 0f
}
// Plain mutable Long slot — written inside draw lambda. Not a Compose state, so the
// write does NOT invalidate the composition (avoids self-recomposition loop).
val lastRenderedVersion = remember { longArrayOf(-1L) }
val lodX = remember { FloatArray(TieredBuffer.TOTAL_CAPACITY) }
val lodY = remember { FloatArray(TieredBuffer.TOTAL_CAPACITY) }
val path = remember { Path() }
// T9: zero-alloc Y-range out param. Layout: [0] = yMin, [1] = yMax.
val yRangeOut = remember { FloatArray(2) }
// Cross-frame caches read by pointer-input lambdas. Plain LongArray slots (NOT Compose
// state) — writes inside draw must NOT invalidate composition. Pointer-input lambdas
// read the most recently-rendered values (1-frame lag is acceptable for gestures).
// Layout: [0] = latestMs, [1] = windowStartMs, [2] = windowMs.
val interactionCache = remember { longArrayOf(Long.MIN_VALUE, 0L, 0L) }
val baseModifier = modifier.background(theme.backgroundColor)
val gestureModifier = if (interaction != null) {
baseModifier
.pointerInput(interaction) {
detectTransformGestures { _, _, zoom, _ ->
if (zoom != 1f) interaction.applyZoom(zoom, fallbackXWindowSeconds = xWindowSeconds)
}
}
.pointerInput(interaction) {
detectDragGestures { change, dragAmount ->
val winMs = interactionCache[2]
val latestMs = interactionCache[0]
if (winMs <= 0L || latestMs == Long.MIN_VALUE) return@detectDragGestures
val chartW = (size.width.toFloat() - chartLeftPx).coerceAtLeast(1f)
// Drag right (positive dragAmount.x) → look earlier → negative delta.
val deltaMs = -((dragAmount.x / chartW) * winMs).toLong()
interaction.applyPan(deltaMs, latestMs)
change.consume()
}
}
.pointerInput(interaction) {
detectTapGestures { offset ->
if (interaction.crosshair != null) {
interaction.toggleCrosshair(null)
return@detectTapGestures
}
val winMs = interactionCache[2]
val winStartMs = interactionCache[1]
if (winMs <= 0L) return@detectTapGestures
val signals = state.signals
if (signals.isEmpty()) return@detectTapGestures
val tsAtPixel = InverseProjection.pixelXToTimestampMs(
pixelX = offset.x,
chartLeft = chartLeftPx,
chartRight = size.width.toFloat(),
windowStartMs = winStartMs,
windowMs = winMs,
)
// Build list once; avoid per-frame Map alloc.
val values = ArrayList<SignalValueAt>(signals.size)
for ((name, entry) in signals) {
if (!entry.config.visible) continue
val v = InverseProjection.nearestSampleValue(entry, tsAtPixel)
values.add(SignalValueAt(name, v))
}
interaction.toggleCrosshair(
CrosshairState(
pixelX = offset.x,
timestampMs = tsAtPixel,
signalValues = values,
)
)
}
}
} else baseModifier
Canvas(modifier = gestureModifier) {
val currentVersion = state.dataVersion
// Recompose may run when interaction state (crosshair / mode) changes even if
// dataVersion did not — so still draw when interaction is non-null and crosshair
// is active (to keep overlay glued to canvas across resize / scroll).
val interactionActive = interaction != null &&
(interaction.crosshair != null || interaction.mode !is ViewportMode.Following || interaction.xWindowSecondsOverride > 0f)
if (currentVersion == lastRenderedVersion[0] && !interactionActive) return@Canvas
// T9: cached entry array — zero-alloc iteration in steady-state.
val signalsArr = state.signalsArray
val t0 = state.resolvedT0Ms ?: return@Canvas
if (signalsArr.isEmpty()) return@Canvas
val effectiveXWindowSec =
if (interaction != null && interaction.xWindowSecondsOverride > 0f) interaction.xWindowSecondsOverride
else xWindowSeconds
val windowMs = (effectiveXWindowSec * 1000f).toLong()
if (windowMs <= 0L) return@Canvas
val chartBottom = size.height - chartBottomInsetPx
val chartW = size.width - chartLeftPx
val pixelWidth = chartW.toInt().coerceAtLeast(1)
var latestMs = Long.MIN_VALUE
for (i in signalsArr.indices) {
val ts = signalsArr[i].buffer.latestTimestampMs()
if (ts > latestMs) latestMs = ts
}
if (latestMs == Long.MIN_VALUE) return@Canvas
// Apply interaction viewport offset (History mode shifts window back from live edge).
val viewportOffsetMs = interaction?.viewportOffsetMs ?: 0L
val viewportRightMs = latestMs + viewportOffsetMs
val windowStartMs = viewportRightMs - windowMs
// Publish to pointer-input cache for next-frame gesture handlers.
interactionCache[0] = latestMs
interactionCache[1] = windowStartMs
interactionCache[2] = windowMs
// Single snapshot pass per signal (T11). Per-signal scratch arrays live in SignalEntry.
// Y-range scan and path generation both read the same snapshot — no double-snapshot.
var dataMin = 0f
var dataMax = 0f
var hasData = false
for (i in signalsArr.indices) {
val entry = signalsArr[i]
if (!entry.config.visible) { entry.scratchCount = 0; continue }
val n = entry.buffer.snapshot(windowStartMs, windowMs, entry.scratchTs, entry.scratchV)
entry.scratchCount = n
for (j in 0 until n) {
val v = entry.scratchV[j]
if (!hasData) { dataMin = v; dataMax = v; hasData = true }
else {
if (v < dataMin) dataMin = v
if (v > dataMax) dataMax = v
}
}
}
if (!hasData) { dataMin = -1f; dataMax = 1f }
resolveYRange(config, dataMin, dataMax, yRangeOut)
val yMin = yRangeOut[0]
val yMax = yRangeOut[1]
drawXAxis(windowStartMs, windowMs, theme, textMeasurer, config.axis.xLabelMode, chartLeftPx, chartBottom, t0, showGrid = config.axis.showGrid)
drawYAxis(yMin, yMax, theme, textMeasurer, config.axis.yLabelMode, config.axis.yLabelDecimals, chartLeftPx, chartBottom, showGrid = config.axis.showGrid)
for (i in signalsArr.indices) {
val entry = signalsArr[i]
// Decimate via configured strategy (outside renderer).
val pairCount = lodStrategy.decimate(
timestamps = entry.scratchTs,
values = entry.scratchV,
count = entry.scratchCount,
windowStartMs = windowStartMs,
windowMs = windowMs,
pixelWidth = pixelWidth,
outX = lodX,
outY = lodY,
)
// Delegate to per-signal renderer with primitive params.
with(entry.config.renderer) {
drawSignal(
color = entry.config.color,
strokeWidth = entry.config.strokeWidth,
visible = entry.config.visible,
lodX = lodX,
lodY = lodY,
count = pairCount,
path = path,
chartLeft = chartLeftPx,
chartRight = size.width,
chartBottom = chartBottom,
yMin = yMin,
yMax = yMax,
)
}
}
// Crosshair overlay (drawn last → above signals + axes).
val crosshair = interaction?.crosshair
if (crosshair != null) {
drawCrosshair(
crosshair = crosshair,
state = state,
theme = theme,
textMeasurer = textMeasurer,
chartLeft = chartLeftPx,
chartRight = size.width,
chartBottom = chartBottom,
yMin = yMin,
yMax = yMax,
)
}
lastRenderedVersion[0] = currentVersion
}
}
/**
* Draw vertical guide line at `crosshair.pixelX`, per-signal dot markers at the inverse-
* projected timestamp, and a top-right readout box with timestamp + per-signal values.
*
* Stateless / zero-buffer-access — reads `entry.scratch*` already populated by the
* surrounding draw pass + per-signal values already resolved in [CrosshairState.signalValues].
*/
private fun DrawScope.drawCrosshair(
crosshair: CrosshairState,
state: RealtimeChartState,
theme: ChartTheme,
textMeasurer: TextMeasurer,
chartLeft: Float,
chartRight: Float,
chartBottom: Float,
yMin: Float,
yMax: Float,
) {
val px = crosshair.pixelX.coerceIn(chartLeft, chartRight)
// Vertical guide
drawLine(
color = theme.axisColor,
start = Offset(px, 0f),
end = Offset(px, chartBottom),
strokeWidth = theme.strokeWidth,
)
val yRange = (yMax - yMin).coerceAtLeast(1e-6f)
val invY = 1f / yRange
// Per-signal dots
val signals = state.signals
for (sv in crosshair.signalValues) {
if (sv.value.isNaN()) continue
val entry = signals[sv.signalName] ?: continue
val yPx = chartBottom - ((sv.value - yMin) * invY) * chartBottom
drawCircle(
color = entry.config.color,
radius = 4f,
center = Offset(px, yPx),
)
}
}