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>
299 lines
13 KiB
Kotlin
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),
|
|
)
|
|
}
|
|
}
|