# 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. ```bash ./gradlew :chart-realtime:publishToMavenLocal ``` ```kotlin // 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 ```kotlin @Composable fun MyChart(sensorFlow: Flow) { 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: ```kotlin 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 ```kotlin state.addSignal("noise", SignalConfig( color = Color.Red, strokeWidth = 1f, renderer = MyCustomRenderer, // implement SignalRenderer )) ``` ## Interaction ```kotlin 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 ```kotlin // Pre-timestamped samples state.collectFrom("ecg", ecgFlow, scope) // Flow> // Auto-timestamp via kotlin.time.Clock state.collectFrom("accel_x", accelFlow, scope) // Flow ``` ## 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`](../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 (Volatile, copy-on-write) ├─ signalsArray: cached Array (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 - [`CHANGELOG.md`](../CHANGELOG.md) — all releases since `0.1.0` - [`CONTRIBUTING.md`](../CONTRIBUTING.md) — build instructions + PR workflow + style guide - [`docs/PERFORMANCE.md`](../docs/PERFORMANCE.md) — measured frame time / memory / allocation numbers - [`.paul/ROADMAP.md`](../.paul/ROADMAP.md) — milestone history + backlog ## License MIT. See [`LICENSE`](../LICENSE).