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> |
||
|---|---|---|
| .. | ||
| api | ||
| src | ||
| build.gradle.kts | ||
| README.md | ||
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 in1.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 implementLodStrategy - Pluggable rendering: implement
SignalRendererfor scatter / area / bar (line default ships) - Pluggable axis formatters:
TimeAxisFormatter/DecimalAxisFormatter/DateTimeAxisFormatter/UnitAxisFormatter("mV"), or implementAxisFormatter - 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
Followingmode
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 MinMaxLttbLodStrategymeasured at 1.80× speedup vs pure LTTB on Kotlin/Native scalar code (paper claims 10× under Rust + SIMD)- Render hot path: 0
Pair/Map/Iteratorallocations 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.mdfor full numbers
Limitations (honest)
- Line rendering only in default impl. Scatter / area / bar require custom
SignalRendererimpl - 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
CHANGELOG.md— all releases since0.1.0CONTRIBUTING.md— build instructions + PR workflow + style guidedocs/PERFORMANCE.md— measured frame time / memory / allocation numbers.paul/ROADMAP.md— milestone history + backlog
License
MIT. See LICENSE.