KMPCharts/chart-realtime
Trentin Davide 0e3360e4a0 perf(chart-realtime): cut per-frame render CPU on realtime chart
Profiling (Pixel 7 + SM-T819) showed the chart CPU-bound in Skia
analytic-AA path fill plus a full-buffer copy on every frame.

- LineSignalRenderer: draw the signal polyline with anti-aliasing off,
  via a cached common Paint + drawIntoCanvas (KMP-safe, no nativeCanvas).
  Flips Skia from CPU coverage-mask raster to GPU tessellation;
  configured strokeWidth still honored. Removes the old Stroke cache.
- TieredBuffer/CircularBuffer: replace the full ~60k-sample copy+scan in
  the draw hot path with an O(log n) bisect window read (copyWindow).
  Output byte-identical (equivalence tests incl. ring-wrap + edges).
- RealtimeChart: split the single Canvas into a cold layer (Y-axis line,
  grid, labels) and a hot layer (signal, X-axis, crosshair) so per-frame
  TextMeasurer layout stops running every frame. Y range stabilized to
  the tick grid and shared by both layers (signal never clips).
- minSdk 26 -> 24.

Renderer unit test moved commonTest -> iosTest (Compose Paint delegates
to a non-mockable android.graphics.Paint stub on the JVM host; Skiko
backs it for real). Verified on SM-T819 (release): frame time 150ms ->
48ms, ~10 -> ~31 fps at full 8x200 Hz load.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 11:39:00 +02:00
..
api feat(chart): v0.5.0 architecture + interaction 2026-05-22 00:05:43 +02:00
src perf(chart-realtime): cut per-frame render CPU on realtime chart 2026-07-23 11:39:00 +02:00
build.gradle.kts perf(chart-realtime): cut per-frame render CPU on realtime chart 2026-07-23 11:39:00 +02:00
README.md chore(portfolio): v1.0.0 prep — docs, CI, demos 2026-05-22 10:54:26 +02:00

chart-realtime

Real-time line chart for Compose Multiplatform (Android + iOS). Optimized for sensor data: 200Hz+ multi-signal, bounded memory, 1h+ rolling window, zero-alloc render path.

Status: 0.5.0-SNAPSHOT — pre-1.0.0. API is stable on the public surface; Maven Central publishing lands in 1.0.0.

Why it exists

Existing KMP chart libraries (Vico, KoalaPlot, AAY-chart) target static datasets and snapshot-style updates. None target sustained high-frequency streaming sensor data with bounded memory. chart-realtime fills that gap.

Features

  • Multi-signal rendering on a single Canvas — add / remove signals at runtime
  • Tiered ring buffer (5min full-rate + 10min @ 10Hz + 45min @ 1Hz) — 1h of data per signal at ~1.14 MB
  • Pluggable LoD: MinMaxLttbLodStrategy (SOTA, default) / MinMaxLodStrategy / LttbLodStrategy, or implement LodStrategy
  • Pluggable rendering: implement SignalRenderer for scatter / area / bar (line default ships)
  • Pluggable axis formatters: TimeAxisFormatter / DecimalAxisFormatter / DateTimeAxisFormatter / UnitAxisFormatter("mV"), or implement AxisFormatter
  • Interaction layer: pinch zoom, drag pan, tap crosshair with per-signal values. Swipe to right edge resumes auto-scroll
  • Data-driven recomposition via Compose snapshots — idle = 0 recompositions
  • Material3 dynamic theming, edge-to-edge / camera-cutout safe
  • Thread-safe push from any background thread (Snapshot.withMutableSnapshot
    • retry helper)
  • ABI lockdown via Binary Compatibility Validator
  • Zero-alloc render at steady state (cached Stroke / TextStyle / signalsArray / per-signal scratch arrays)

Supported targets

  • Android (compileSdk 35, minSdk 26)
  • iOS arm64 (real devices) + iosSimulatorArm64 (Apple Silicon simulators)
  • Intel Mac simulators (iosX64) not supported since v0.5.0 (Compose Multiplatform 1.11.0 dropped the variant; Apple deprecated Intel iOS sims since Xcode 15+)

Install

Pre-1.0.0: consume from Maven Local.

./gradlew :chart-realtime:publishToMavenLocal
// settings.gradle.kts
dependencyResolutionManagement {
    repositories { mavenLocal(); google(); mavenCentral() }
}

// build.gradle.kts
implementation("dev.dtrentin:chart-realtime:0.5.0-SNAPSHOT")

Post-1.0.0: Maven Central (planned).

Quick start

@Composable
fun MyChart(sensorFlow: Flow<Float>) {
    val state = remember { RealtimeChartState(ChartConfig()) }
    val interaction = rememberChartInteractionState()

    LaunchedEffect(state) {
        state.addSignal("ecg", SignalConfig(color = Color.Green))
        sensorFlow.collect { v ->
            state.push("ecg", System.currentTimeMillis(), v)
        }
    }

    RealtimeChart(
        state = state,
        modifier = Modifier.fillMaxSize(),
        interaction = interaction,
    )
}

Configuration

ChartConfig is split into three orthogonal sub-configs:

ChartConfig(
    data = DataConfig(
        xWindowSeconds = 10f,
        yRange = YRange.Auto(),
        t0 = T0.FirstSample,
    ),
    axis = AxisConfig(
        xLabelMode = AxisLabelMode.INSIDE,
        yLabelMode = AxisLabelMode.BESIDE,
        yLabelDecimals = 2,
        showGrid = true,
        xFormatter = TimeAxisFormatter,
        yFormatter = UnitAxisFormatter("mV", decimals = 2),
    ),
    render = RenderConfig(
        theme = ChartTheme(),
        frameRate = FrameRate.Display,
        lodStrategy = MinMaxLttbLodStrategy(),
    ),
)

Per-signal renderer override

state.addSignal("noise", SignalConfig(
    color = Color.Red,
    strokeWidth = 1f,
    renderer = MyCustomRenderer,  // implement SignalRenderer
))

Interaction

val interaction = rememberChartInteractionState(
    config = InteractionConfig(
        zoomEnabled = true,
        panEnabled = true,
        tapCrosshairEnabled = true,
        minXWindowSeconds = 0.5f,
        maxXWindowSeconds = 3600f,
        resumeFollowingThresholdMs = 500L,
    )
)
RealtimeChart(state, modifier, interaction = interaction)

Gestures:

  • Pinch — zoom (clamps to minXWindowSeconds / maxXWindowSeconds)
  • Drag — pan; mode switches to History(anchorMs)
  • Tap — toggle crosshair; shows per-signal value at the tapped timestamp
  • Pan back to right edge — auto resumes Following mode

State exposed: mode: ViewportMode { Following | Frozen | History(anchorMs) }, crosshair: CrosshairState?, xWindowSecondsOverride: Float, viewportOffsetMs: Long.

Flow ingest API

// Pre-timestamped samples
state.collectFrom("ecg", ecgFlow, scope)  // Flow<Pair<Long, Float>>

// Auto-timestamp via kotlin.time.Clock
state.collectFrom("accel_x", accelFlow, scope)  // Flow<Float>

Performance

  • 207 tests on iosSimulatorArm64Test, all green at every release
  • MinMaxLttbLodStrategy measured at 1.80× speedup vs pure LTTB on Kotlin/Native scalar code (paper claims 10× under Rust + SIMD)
  • Render hot path: 0 Pair / Map / Iterator allocations per frame at steady state
  • Memory per signal: ~1.14 MB for 1h of data @ 200Hz
  • Bisect-based TieredBuffer.snapshotWindow — 30× fewer per-frame copies on 10s windows in 5min Tier0 buffers
  • See docs/PERFORMANCE.md for full numbers

Limitations (honest)

  • Line rendering only in default impl. Scatter / area / bar require custom SignalRenderer impl
  • Single Y axis per chart. No multi-Y, no log scale (planned post-1.0.0)
  • No annotations / threshold lines yet (backlog)
  • No headless export (PNG / CSV) — render is Compose-only
  • No SwiftUI demo app yet (planned for 1.0.0); iOS targets compile, link, test, ship XCFramework
  • No Maven Central yet — Maven Local only pre-1.0.0

Architecture

RealtimeChart (composable)
  ├─ Canvas draw lambda
  │    ├─ reads state.dataVersion (Compose-observable)
  │    ├─ for each signal:
  │    │    ├─ TieredBuffer.snapshot → SignalEntry.scratch (1× per frame)
  │    │    ├─ LodStrategy.decimate → lodX / lodY (1× per frame)
  │    │    └─ SignalRenderer.drawSignal (lodX, lodY, count, ...)
  │    └─ AxisRenderer.drawXAxis / drawYAxis
  ├─ pointer-input chain (when interaction != null)
  │    ├─ detectTransformGestures → applyZoom
  │    ├─ detectDragGestures → applyPan
  │    └─ detectTapGestures → toggleCrosshair
  └─ overlay: crosshair guide + per-signal dots + readout (when crosshair set)

RealtimeChartState
  ├─ _signals: Map<String, SignalEntry> (Volatile, copy-on-write)
  ├─ signalsArray: cached Array<SignalEntry> (invalidated on add/remove)
  ├─ _dataVersion: mutableLongStateOf (Compose-observable counter)
  └─ resolvedT0Ms: Long? (lazy from T0.FirstSample)

Requirements

  • Android minSdk 26, compileSdk 35
  • Kotlin 2.3.21+
  • Compose Multiplatform 1.11.0+ (or AndroidX Compose equivalent)
  • JDK 21 toolchain for builds

Documentation

License

MIT. See LICENSE.