Ship 15-task plan via kmp-manager group-by-group flow. Library moves
from "prototype" to "senior-engineer-built KMP library" per 3-reviewer
audit (android-reviewer + mobile-performance-reviewer + mobile-architect).
Critical fixes:
- LICENSE MIT (T1) — legal blocker resolved
- explicitApi() Strict + BCV 0.16.3 + klib enabled (T2) — ABI locked
baseline: chart-realtime.api 161 LOC + chart-realtime.klib.api 211 LOC
- Internal lockdown: CircularBuffer, TieredBuffer, LodDecimator,
dataVersion, rememberFrameTick (T3)
- kotlinx-datetime 0.6.2 replaces expect/actual currentTimeMs (T4)
Platform.kt + .android.kt + .ios.kt deleted
- CircularBuffer.writeIndex Int -> Long (T5) — fix 124d overflow @ 200Hz
- Remove !! and @Suppress("UNUSED_EXPRESSION") from commonMain (T6)
- Remove dead config: maxBufferSeconds, xLabelDecimals,
SignalConfig.label (T7)
Correctness:
- POSITIVE_INFINITY/Float.MAX_VALUE sentinels -> hasData flag (T8)
- Snapshot.withMutableSnapshot + SnapshotApplyConflictException retry
in push/addSignal/removeSignal (T9)
- dataVersion mutableLongStateOf — Compose-observable, idle = 0
recompositions (T10) — RenderLoop.kt deleted, targetFps @Deprecated
- Single buffer.snapshot() per signal per frame via SignalEntry
per-signal scratch arrays (T11) — render-time snapshot cost halved
- M4 binning Tier1/Tier2: firstTs/V + minTs/V + maxTs/V + lastTs/V
with chronological dedup (T12) — no C7 vertical spike artifacts.
TIER1/TIER2 CAPACITY x2 -> x4 (TOTAL 77.4k -> 94.8k)
- NaN/Inf/backward-timestamp guards on push (T13) — silent drop,
per-signal lastPushedTs tracking, KDoc contract
Test gap fill:
- NumberFormat extracted from AxisRenderer to internal object (T14)
+38 tests pinning rounding/scientific/time-format behavior
- TieredBufferTest +15 tests: tier roll, window-crossing, clear,
edge cases (T15)
- LodDecimatorTest +10 LTTB-specific branch coverage tests (T15)
Test count: 33 -> 107 (+74).
ABI: 211/272 -> 161/211 LOC (locked, baseline committed).
Deferred to v0.5.0:
- Bump kotlinx-datetime 0.6.2 -> 0.7.x (needs Kotlin 2.1.20+)
- Remove ChartConfig.targetFps (currently @Deprecated, ignored)
- NumberFormat Long overflow @ 1e19 (safe via current routing)
- LTTB upper-bound inclusive vs snapshot half-open semantic mismatch
- Macrobench setup for -30% frame-time verification
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
110 lines
3.2 KiB
Markdown
110 lines
3.2 KiB
Markdown
# chart-realtime
|
|
|
|
KMP real-time line chart for Android. Renders multiple sensor signals at 200Hz+ without frame drops.
|
|
|
|
## Features
|
|
|
|
- Multiple signals on one chart, added/removed dynamically
|
|
- FIFO circular buffer — lock-free writes at 200Hz+, safe render reads at 60fps
|
|
- Level-of-Detail decimation — renders O(pixels) regardless of buffer size (1h+ of data)
|
|
- Dirty-flag render: skips GPU draw when no new data since last frame
|
|
- Configurable render rate: default 30 fps, override via `targetFps` (pass `null` for max display Hz)
|
|
- X axis: scrolling window, seconds from configurable t0
|
|
- Y axis: auto-fit or fixed range
|
|
- Material3 default theme, fully customizable via `ChartTheme`
|
|
|
|
## Installation
|
|
|
|
```kotlin
|
|
// settings.gradle.kts
|
|
include(":chart-realtime")
|
|
```
|
|
|
|
Or Maven local:
|
|
```bash
|
|
./gradlew :chart-realtime:publishToMavenLocal
|
|
```
|
|
|
|
```kotlin
|
|
// build.gradle.kts
|
|
implementation("dev.dtrentin:chart-realtime:0.5.0-SNAPSHOT")
|
|
```
|
|
|
|
## Quick start (3 lines)
|
|
|
|
```kotlin
|
|
val state = remember { RealtimeChartState(ChartConfig()) }
|
|
LaunchedEffect(state) {
|
|
state.addSignal("ecg", SignalConfig(color = Color.Green))
|
|
sensorFlow.collect { v -> state.push("ecg", System.currentTimeMillis(), v) }
|
|
}
|
|
RealtimeChart(state = state, modifier = Modifier.fillMaxSize())
|
|
```
|
|
|
|
## Configuration
|
|
|
|
```kotlin
|
|
ChartConfig(
|
|
xWindowSeconds = 10f, // visible window width
|
|
yRange = YRange.Auto(), // or YRange.Fixed(-1f, 1f)
|
|
t0 = T0.FirstSample, // or T0.Fixed(epochMs)
|
|
targetFps = 30, // default 30 fps; null = max display refresh rate
|
|
theme = ChartTheme( // optional visual override
|
|
backgroundColor = Color(0xFF1A1A2E),
|
|
gridColor = Color(0x22FFFFFF),
|
|
axisColor = Color(0xFFBBBBBB),
|
|
),
|
|
)
|
|
```
|
|
|
|
## Level of Detail
|
|
|
|
```kotlin
|
|
ChartConfig(lodMode = LodMode.MIN_MAX) // default — zero-alloc, preserves peaks
|
|
ChartConfig(lodMode = LodMode.LTTB) // visually smooth, slight alloc per frame
|
|
```
|
|
|
|
## Axis Labels
|
|
|
|
```kotlin
|
|
ChartConfig(
|
|
xLabelMode = AxisLabelMode.INSIDE, // default — labels over chart area
|
|
yLabelMode = AxisLabelMode.BESIDE, // reserved margin for Y labels
|
|
)
|
|
// AxisLabelMode.HIDDEN — hides labels on that axis
|
|
```
|
|
|
|
## Material3 Theme
|
|
|
|
```kotlin
|
|
val theme = rememberMaterialChartTheme()
|
|
RealtimeChart(state = state, theme = theme)
|
|
```
|
|
|
|
## Flow API
|
|
|
|
```kotlin
|
|
// From Flow<Float> (auto-timestamp)
|
|
state.collectFrom("accel_x", accelerometerFlow, viewModelScope)
|
|
|
|
// From Flow<Pair<Long, Float>> (explicit timestamp)
|
|
state.collectFrom("ecg", ecgFlow, viewModelScope) // ecgFlow emits (timestampMs, value)
|
|
```
|
|
|
|
## Performance notes
|
|
|
|
- Data thread pushes at any rate; render thread reads at `targetFps` (default 30) or display refresh if null
|
|
- TieredBuffer keeps ~1h history per signal (3-tier ring, no caller config)
|
|
- LoD decimation reduces >pixel-count data to min/max per pixel column
|
|
- Pre-allocated arrays — zero GC on hot render path (except LoD scratch, see TODO in `LodDecimator.kt`)
|
|
- Dirty-flag skips canvas draw when buffer unchanged since last frame
|
|
|
|
## Requirements
|
|
|
|
- Android minSdk 26
|
|
- Jetpack Compose / Compose Multiplatform 1.8+
|
|
- Kotlin 2.1+
|
|
|
|
## License
|
|
|
|
MIT License — see [LICENSE](../LICENSE).
|