KMPCharts/chart-realtime/README.md
Trentin Davide 07197ed8df
Some checks are pending
CI / build (push) Waiting to run
chore(portfolio): v1.0.0 prep — docs, CI, demos
Autonomous portfolio prep batch. All gates green: 207 tests pass on
iosSimulatorArm64Test, assembleRelease green, apiCheck green, app builds.

Docs:
- CHANGELOG.md (Keep-a-Changelog, retroactive v0.1-v0.5)
- CONTRIBUTING.md (build + style + PR workflow + perf rules)
- chart-realtime/README.md rewrite for v0.5.0 API surface
- docs/PERFORMANCE.md (LoD perf table, alloc budget, memory budget,
  stability report, threading model)

CI (.github/):
- workflows/ci.yml — macos-latest, JDK 21, konan cache, full gate
- workflows/release.yml — tag-triggered, GitHub release with XCFramework
  + AAR; Maven Central as commented TODO (needs OSSRH + GPG secrets)
- dependabot.yml — weekly gradle + actions updates
- pull_request_template.md + 2 issue templates (bug, feature)

Android demo polish:
- DemoScreen + ControlPanel (Material3 surface)
- Sample rate slider (10..200Hz), 1-6 signal count, per-signal waveform
  picker (sine/square/triangle/noise), window seconds, LoD picker, Y
  label mode, Y formatter, interaction toggle, theme switch, status chips
- Single producer coroutine (cleaner crosshair readout, easier rate
  accounting)
- SignalGenerator + WaveformType + DemoConfig + PushCounter

iOS demo scaffold (samples/ios/ChartRealtimeDemo/):
- SPM Package.swift with local binaryTarget pointing to
  chart-realtime/build/XCFrameworks/debug/ChartRealtime.xcframework
- DemoApp.swift + DemoView.swift (smoke test confirming Kotlin types
  accessible from Swift)
- README documents the ComposeUIViewController shim needed in
  chart-realtime/src/iosMain/ for full SwiftUI hosting (deferred to
  v1.0.0 T1)
- xcodebuild against iOS Simulator builds green

Debt:
- gradle.properties: removed kotlin.internal.klibs.non-packed=false
  (Compose-MP 1.11.0 final no longer needs the bypass; apiCheck +
  native compile + 207 tests still green)
- 8 remaining deprecation warnings tied to com.android.library + KMP
  plugin combo; clearing them requires migration to
  com.android.kotlin.multiplatform.library 9.2.0 which forces ABI
  baseline regen + Maven publish restructure + XCFramework hierarchy
  expansion. Deferred as dedicated ticket.

Skipped (need user):
- CODE_OF_CONDUCT.md (content classifier blocks Contributor Covenant
  verbatim)
- Maven Central T1 (Sonatype + GPG)
- Benchmark vs competitors T4 (methodology + Maven coords)
- Logo/banner/GIF T7 (design assets)
- Tag + GitHub release T10 (rename pending)

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

7.3 KiB
Raw Blame History

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.