feat(chart-realtime): v0.3.0 — dirty flag render, targetFps=30, iOS targets, KDoc, README

- Dirty-flag render: dataVersion counter in RealtimeChartState, skip Canvas draw
  when no new data since last frame (zero GPU work when sensor idle)
- targetFps=30 default in ChartConfig (pass null for max display refresh rate)
- iOS targets: iosX64, iosArm64, iosSimulatorArm64 + Platform.ios.kt actual
- KDoc: RealtimeChart params, dataVersion, ChartConfig.targetFps, LodMode entries,
  AxisLabelMode entries
- README: fixed Maven coords, added LoD/AxisLabels/Material3 sections, updated
  feature list and performance notes
- Tests: RealtimeChartStateTest (17 tests, dataVersion + signal lifecycle)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dtrentin 2026-05-21 11:11:37 +02:00
parent 99f7ee1252
commit 73bbd9ddec
12 changed files with 479 additions and 145 deletions

View file

@ -2,123 +2,70 @@
## Overview ## Overview
Build a standalone KMP real-time chart module from scaffold to polished publishable library. Six phases: module setup → core data structures → state API → rendering engine → demo validation → polish. Phases 2+3 parallelizable; Phase 4 is the critical path. KMP real-time chart library. Six phases v0.1.0 MVP shipped; v0.2.0 polish shipped.
## Current Milestone ## Milestones
**v0.1.0 — MVP** | Milestone | Version | Status | Completed |
Status: Not started |-----------|---------|--------|-----------|
Phases: 0 of 6 complete | v0.1.0 MVP | 0.1.0 | ✓ Shipped | 2026-05-20 |
| v0.2.0 Polish | 0.5.0-SNAPSHOT | ✓ Shipped | 2026-05-21 |
## Phases | v0.3.0 (TBD) | - | Not started | - |
| Phase | Name | Plans | Status | Completed |
|-------|------|-------|--------|-----------|
| 1 | Module Scaffold | 2 | Not started | - |
| 2 | Buffer + LoD | 2 | Not started | - |
| 3 | State API | 2 | Not started | - |
| 4 | Render Engine | 3 | Not started | - |
| 5 | Demo App | 1 | Not started | - |
| 6 | Polish + Docs | 2 | Not started | - |
## Phase Details
### Phase 1: Module Scaffold
**Goal:** Buildable KMP module structure + Gradle config ready for code
**Depends on:** Nothing
**Research:** Unlikely (standard KMP module setup)
**Scope:**
- KMP module `:chart-realtime` with commonMain / androidMain
- Version catalog entries (Compose, coroutines, kotlin.test)
- Demo app module `:app` depending on `:chart-realtime`
- Empty placeholder classes to confirm build passes
**Plans:**
- [ ] 01-01: KMP module + Gradle + version catalog
- [ ] 01-02: Demo app module + dependency wiring
### Phase 2: Buffer + LoD
**Goal:** Core data structures fully tested — CircularBuffer and LodDecimator
**Depends on:** Phase 1 (module builds)
**Research:** Unlikely (known algorithms)
**Scope:**
- `CircularBuffer` — pre-allocated FloatArray[timestamp, value], lock-free write, indexed read
- `LodDecimator` — min/max envelope per pixel column, O(n) single pass
- Unit tests for both (edge cases: empty, full, wrap-around, single point, fewer pts than pixels)
**Plans:**
- [ ] 02-01: CircularBuffer implementation + tests
- [ ] 02-02: LodDecimator implementation + tests
### Phase 3: State API
**Goal:** Public API surface complete — RealtimeChartState usable by consumers
**Depends on:** Phase 2 (CircularBuffer ready)
**Research:** Unlikely
**Scope:**
- `ChartConfig` data class (xWindowSeconds, yRange, t0, targetFps, maxBufferSeconds)
- `SignalConfig` data class (name, color, strokeWidth, visible)
- `YRange` sealed class (Auto, Fixed)
- `T0` sealed class (FirstSample, Fixed)
- `RealtimeChartState` — holds N CircularBuffers, addSignal/removeSignal, push(), collectFrom()
**Plans:**
- [ ] 03-01: Config models + sealed classes
- [ ] 03-02: RealtimeChartState + Flow collector
### Phase 4: Render Engine
**Goal:** Chart renders correctly at 200Hz with stable axes and smooth frame rate
**Depends on:** Phase 2 (LoD) + Phase 3 (State)
**Research:** Unlikely (Canvas API known)
**Scope:**
- `RenderLoop` — withFrameNanos, targetFps throttle, reads state snapshot
- `AxisRenderer` — X/Y gridlines, tick interval math, labels (DrawScope)
- `SignalRenderer` — FloatArray path build from LodDecimator output, drawLines
- `RealtimeChart()` composable — assembles all renderers, exposes ChartConfig + RealtimeChartState
**Plans:**
- [ ] 04-01: RenderLoop + AxisRenderer
- [ ] 04-02: SignalRenderer + path building
- [ ] 04-03: RealtimeChart composable integration
### Phase 5: Demo App
**Goal:** Running Android app proves 200Hz+ with 5+ signals without frame drops
**Depends on:** Phase 4 (rendering complete)
**Research:** Unlikely
**Scope:**
- Simulated sensor data source (coroutine emitting at 200Hz)
- 5 signals with distinct colors on single chart
- ChartConfig controls: xWindow, yRange mode, signal toggle
- Verify ≥55fps sustained via Android Studio profiler
**Plans:**
- [ ] 05-01: Demo app with 5-signal 200Hz simulation
### Phase 6: Polish + Docs
**Goal:** Publishable library with Material3 theming and minimal API docs
**Depends on:** Phase 5 (demo validated)
**Research:** Unlikely
**Scope:**
- Material3 default colors/typography for axis labels
- ChartTheme override support
- KDoc on all public API surface
- README with usage example (3-line integration)
- Maven local publish config
**Plans:**
- [ ] 06-01: Material3 theming + ChartTheme
- [ ] 06-02: KDoc + README + publish config
--- ---
*Roadmap created: 2026-05-20*
## v0.1.0 — MVP (SHIPPED)
Status: ✓ Complete
Phases: 6 of 6 complete
### Phases
| Phase | Name | Status | Completed |
|-------|------|--------|-----------|
| 1 | Module Scaffold | ✓ Done | 2026-05-20 |
| 2 | Buffer + LoD | ✓ Done | 2026-05-20 |
| 3 | State API | ✓ Done | 2026-05-20 |
| 4 | Render Engine | ✓ Done | 2026-05-20 |
| 5 | Demo App | ✓ Done | 2026-05-20 |
| 6 | Polish + Docs | ✓ Done | 2026-05-20 |
Delivered: CircularBuffer, LodDecimator, RealtimeChart composable, basic AxisRenderer.
---
## v0.2.0 — Polish (SHIPPED as v0.5.0-SNAPSHOT)
Status: ✓ Complete
Summary: .paul/phases/v0.2.0-plan-SUMMARY.md
### Delivered
- TieredBuffer (3-tier 1h ring buffer, MIN_MAX binning, zero-alloc)
- LodMode enum (MIN_MAX, LTTB) — MEAN removed
- LTTB render-time decimation (O(n) Largest-Triangle-Three-Buckets)
- AxisLabelMode (INSIDE/BESIDE/HIDDEN) with chartLeft/chartBottom insets
- T0-relative X-axis timestamps with mm:ss/hh:mm:ss formatting, negative support
- Material3 dynamic theming (light/dark, wallpaper-based, API 31+)
- Camera cutout / edge-to-edge support
- Sensor domain layer (Clean Architecture)
- Koin 4.0.0 DI wiring
- Package rename: eu.henesis → dev.dtrentin
- Git remote: ssh://git@3nt-git.duckdns.org:222/davide.trentin/KMPCharts.git
- Sensor rate cap: 200Hz (5_000μs)
---
## v0.3.0 — Potential Scope (TBD)
| Item | Effort | Priority |
|------|--------|----------|
| Dirty flag render (skip frame if no new data) | S | High — thermal |
| targetFps=30 default | S | High — thermal |
| KDoc on public API surface | M | Medium |
| README with integration example | S | Medium |
| Publish to Maven Central / GitHub Packages | M | Low |
| iOS expect/actual (beyond currentTimeMs) | L | Future |
---
*Roadmap updated: 2026-05-21*

View file

@ -2,28 +2,28 @@
## Project Reference ## Project Reference
See: .paul/PROJECT.md (updated 2026-05-20) See: .paul/PROJECT.md (updated 2026-05-21)
**Core value:** Android devs plot 200Hz+ multi-signal sensor data without frame drops **Core value:** Android devs plot 200Hz+ multi-signal sensor data without frame drops
**Current focus:** v0.2.0 — Phase 4 LOD scratch optimization (deferred) **Current focus:** v0.3.0 IN PROGRESS — dirty flag render + targetFps=30 shipped
## Current Position ## Current Position
Milestone: v0.2.0 Milestone: v0.2.0-polish
Phase: 3 of 4 — In Progress Phase: 6 of 6 — Complete
Plan: .paul/phases/v0.2.0-plan.md Plan: .paul/phases/v0.2.0-plan.md
Status: In Progress Status: Complete
Last activity: 2026-05-20 — Phases 1-3 complete + build green. rememberMaterialChartTheme(), axis text labels (TextMeasurer), dynamic xWindowSeconds param, theme param on RealtimeChart. Fix: added compose.material3 to commonMain deps. Last activity: 2026-05-21 — dirty flag render, targetFps=30, 17 unit tests, KDoc, README, iOS targets (iosX64/iosArm64/iosSimulatorArm64) + Platform.ios.kt actual.
Progress: Progress:
- Milestone: [██████████] 100% - v0.1.0 milestone: [██████████] 100% SHIPPED
- Phase 6: [██████████] 100% - v0.2.0 milestone: [██████████] 100% SHIPPED
## Loop Position ## Loop Position
``` ```
PLAN ──▶ APPLY ──▶ UNIFY PLAN ──▶ APPLY ──▶ UNIFY
✓ ✓ ✓ [v0.1.0 SHIPPED] ✓ ✓ ✓ [v0.2.0 SHIPPED — v0.5.0-SNAPSHOT]
``` ```
## Accumulated Context ## Accumulated Context
@ -34,28 +34,38 @@ PLAN ──▶ APPLY ──▶ UNIFY
|----------|-------|--------| |----------|-------|--------|
| Canvas custom rendering (no lib) | Init | Core architecture | | Canvas custom rendering (no lib) | Init | Core architecture |
| Render/data decoupled withFrameNanos | Init | RenderLoop design | | Render/data decoupled withFrameNanos | Init | RenderLoop design |
| LoD min/max decimation | Init | SignalRenderer calls LodDecimator | | TieredBuffer over flat ring | v0.2.0 | O(N×constant) memory, 20+ signals |
| compose.foundation added to commonMain deps | Phase 4 | Canvas + Modifier.background require it | | MIN_MAX tier binning (not MEAN) | v0.2.0 | Composable, no artifacts at tier boundaries |
| iOS deferred | Init | commonMain only, no expect/actual beyond currentTimeMs | | MEAN removed from LodMode | v0.2.0 | Cleaner API, no synthetic values |
| LodDecimator as class (pre-alloc scratch) | v0.2.0 | Zero GC at render time |
| Sensor cap 200Hz (5_000μs) | v0.2.0 | Thermal/battery optimization |
| Package dev.dtrentin | v0.2.0 | Personal project, not company |
| iOS targets shipped | v0.3.0 | iosX64/iosArm64/iosSimulatorArm64 + Platform.ios.kt |
### Deferred Issues ### Deferred Issues
| Issue | Origin | Effort | Revisit | | Issue | Origin | Effort | Revisit |
|-------|--------|--------|---------| |-------|--------|--------|---------|
| LodDecimator scratch array allocation per frame | Phase 2 | M | v0.2.0 | | ~~Dirty flag render (skip frame if no new data)~~ | v0.2.0 | S | ✓ v0.3.0 |
| Double snapshot per frame in RealtimeChart | Phase 4 | M | v0.2.0 | | ~~targetFps=30 default~~ | v0.2.0 | S | ✓ v0.3.0 |
| Axis text labels | Phase 4 | M | v0.2.0 — needs TextMeasurer | | ~~iOS expect/actual beyond currentTimeMs~~ | Init | L | ✓ v0.3.0 |
| iOS support | Init | L | Future milestone | | ~~KDoc on public API surface~~ | v0.2.0 | M | ✓ v0.3.0 |
| ~~README with integration example~~ | v0.2.0 | S | ✓ v0.3.0 |
| Publish to Maven Central / GitHub Packages | v0.2.0 | M | v0.3.0 |
### Blockers/Concerns ### Blockers/Concerns
None. None.
## Session Continuity ## Session Continuity
Last session: 2026-05-20 Last session: 2026-05-21
Stopped at: Phases 1-5 complete. 16 tests pass. Stopped at: v0.2.0 complete. All AC pass. Git pushed to remote.
Next action: Phase 6 — 06-01 ChartTheme + Material3, 06-02 KDoc + README + publish config Next action: Define v0.3.0 scope (dirty flag render, targetFps=30, KDoc, publish)
Resume context: All source in chart-realtime/commonMain; demo app in app/ Resume context:
- Library: AndroidCharts/chart-realtime, published dev.dtrentin:chart-realtime:0.5.0-SNAPSHOT to mavenLocal
- App: AndroidChartsApp consuming library, builds clean
- Remote: ssh://git@3nt-git.duckdns.org:222/davide.trentin/KMPCharts.git
- Package: dev.dtrentin.chart
--- ---
*STATE.md — Updated after every significant action* *STATE.md — Updated after every significant action*

View file

@ -0,0 +1,159 @@
---
phase: v0.2.0-polish
plan: v0.2.0
subsystem: chart-realtime
tags: [kmp, compose, canvas, buffer, lod, tiered-buffer, lttb, min-max, axis, material3]
requires:
- phase: v0.1.0-mvp
provides: CircularBuffer, LodDecimator, RealtimeChart composable, basic AxisRenderer
provides:
- TieredBuffer (3-tier 1h ring buffer, MIN_MAX binning, zero-alloc)
- LodMode enum (MIN_MAX, LTTB)
- LTTB render-time decimation
- AxisLabelMode (INSIDE/BESIDE/HIDDEN) with chartLeft/chartBottom insets
- T0-relative X-axis timestamps with mm:ss/hh:mm:ss formatting
- Material3 dynamic theming (light/dark, wallpaper-based)
- Camera cutout / edge-to-edge support
- Sensor domain layer (Clean Architecture)
- Koin DI wiring
- Package: dev.dtrentin.chart
- Git repository on ssh://3nt-git.duckdns.org:222/davide.trentin/KMPCharts.git
affects: [future iOS target, future persistence layer]
tech-stack:
added: [koin-4.0.0, kotlinx-coroutines-android, androidx-lifecycle-runtime-compose]
patterns: [TieredBuffer hot-path write, MIN_MAX composable decimation, LodDecimator as class with pre-alloc scratch]
key-files:
created:
- chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/buffer/TieredBuffer.kt
- chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/LodMode.kt
- chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/AxisLabelMode.kt
- sensor-domain/src/commonMain/kotlin/dev/dtrentin/sensor/domain/
modified:
- chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/buffer/LodDecimator.kt
- chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/buffer/CircularBuffer.kt
- chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/render/AxisRenderer.kt
- chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/render/SignalRenderer.kt
- chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChart.kt
- chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChartState.kt
- chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/ChartConfig.kt
- chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/ChartTheme.kt
key-decisions:
- "TieredBuffer over flat ring: O(signals × constant) memory, scales to 20+ signals"
- "MIN_MAX binning for tiers: composable operation, zero visual discontinuity at boundaries"
- "MEAN removed: introduces values never in raw signal, disinformation for sensor data"
- "LodDecimator as class: pre-alloc scratch arrays, zero GC at render time"
- "Sensor rate capped at 200Hz: thermal/battery optimization"
- "Package dev.dtrentin: personal project, not company"
patterns-established:
- "MIN_MAX composability: MIN(bin_mins)=global_MIN across tier boundaries"
- "TieredBuffer snapshot always time-sorted: tier2→tier1→tier0 with tier0Start=max(windowStart,tier0Boundary)"
- "Render arrays pre-alloc in RealtimeChart via remember { }, zero alloc per frame"
duration: ~8h (multi-session)
started: 2026-05-20T00:00:00Z
completed: 2026-05-21T00:00:00Z
---
# v0.2.0 Plan Summary
**chart-realtime KMP library rebuilt from scratch: TieredBuffer (1h/20-signal), LTTB+MIN_MAX LoD, axis labels with INSIDE/BESIDE/HIDDEN modes, Material3 dynamic theming, Clean Architecture sensor domain — shipped as v0.5.0-SNAPSHOT.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~8h multi-session |
| Started | 2026-05-20 |
| Completed | 2026-05-21 |
| Versions shipped | 0.2.0→0.3.0→0.4.0→0.4.1→0.5.0-SNAPSHOT |
| Git commits | 3 (initial + 2 incremental) |
| Files modified | 20+ |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| Axis text labels visible | Pass | AxisRenderer TextMeasurer, INSIDE/BESIDE/HIDDEN modes |
| Dynamic X-window from UI | Pass | Slider 23600s, StateFlow, MAX_BUFFER_SECONDS=3600 |
| Material3 theme compliance | Pass | rememberMaterialChartTheme(), dynamic color API 31+ |
| User-decided signal colors | Pass | SignalConfig.color, ChartTheme separate |
| Camera cutout / edge-to-edge | Pass | enableEdgeToEdge() + Scaffold innerPadding |
| 1h data support without OOM | Pass | TieredBuffer ~18MB for 6 signals |
| No tier boundary visual artifact | Pass | MIN_MAX composable + tier0 lower-bound fix |
| LodDecimator zero-alloc | Pass | Class with pre-alloc scratch arrays |
| Negative X-axis values | Pass | formatTimeSec handles negative elapsed time |
| Sensor rate cap 200Hz | Pass | SensorRepositoryImpl 5_000μs |
| Package renamed dev.dtrentin | Pass | eu.henesis → dev.dtrentin everywhere |
| Remote git repository | Pass | ssh://3nt-git.duckdns.org:222/davide.trentin/KMPCharts.git |
## Accomplishments
- **TieredBuffer**: 3-tier ring buffer (tier0=5min@200Hz, tier1=10min@10Hz MIN_MAX, tier2=45min@1Hz MIN_MAX). ~18MB for 6 signals vs 259MB naive flat at 1000Hz assumption
- **MIN_MAX composability**: tier boundary artifacts eliminated. MIN(tier_mins)=global_MIN mathematically proven. MEAN removed as inherently dishonest for raw sensor data
- **LTTB algorithm**: O(n) Largest-Triangle-Three-Buckets in LodDecimator. Preserves peaks, smooth trend line, better than mean without signal distortion
- **Axis overhaul**: INSIDE/BESIDE/HIDDEN modes, chartLeft/chartBottom insets, T0-relative timestamps (mm:ss/hh:mm:ss), scientific notation for |y|<0.01 or >1e5, negative values, grid toggle
## Deviations from v0.2.0 Plan
| Type | Count | Description |
|------|-------|-------------|
| Scope additions | 15+ | All features beyond Material3/axis/xWindow/scratch |
| Auto-fixed bugs | 8 | Tier boundary, LTTB samplePtr, X label sticky, Y label clipping, LoD path unravel, etc. |
| Deferred | 1 | iOS expect/actual — still commonMain only |
**Key deviations:**
- Phase 4 (scratch optimization): addressed differently — LodDecimator converted from `object` to `class` with pre-allocated instance fields, not param-passing as planned
- CircularBuffer replaced by TieredBuffer at storage level: larger scope than originally planned
- MEAN LodMode added then removed in same milestone after analysis showed it was harmful
## Key Decisions
| Decision | Rationale | Impact |
|----------|-----------|--------|
| TieredBuffer over flat ring | O(N×constant) memory, scales to 20+ signals at any rate | Core buffer architecture |
| MIN_MAX tier binning | Composable: MIN(mins)=global_MIN, no artifacts | Eliminates tier discontinuity |
| MEAN removed from LodMode | Introduces synthetic values never in raw signal | Cleaner API, honest visualization |
| Sensor cap 200Hz not FASTEST | Thermal/battery optimization, 200Hz exceeds visual need | Reduces CPU interrupts 4-10× |
| dev.dtrentin namespace | Personal project, not company IP | Package identity |
## Bugs Fixed
| Bug | Root Cause | Fix |
|-----|-----------|-----|
| TieredBuffer tier artifact | tier0 snapshot lacked `ts >= tier0BoundaryMs` filter → double-inclusion → col-sort broken | Added `tier0Start = maxOf(windowStart, tier0BoundaryMs)` |
| LoD path unravel | LodDecimator mapped out-of-window samples (tRelMs<0) to x=0 | Added in-window filter before binning |
| X label sticky at axis | `coerceAtLeast(chartLeft)` clamped all early labels to axis line | Replaced with `if (labelX >= chartLeft)` skip |
| Y labels clipped bottom | Used raw `14f` px instead of density-aware sp | `with(this) { 12.sp.toPx() }` |
| Signal line outside chart | No clipRect on drawPath | Added `clipRect(left=chartLeft…)` |
| Negative X shows "0s" | `coerceAtLeast(0L)` | `abs()` + `-` prefix for negative |
| OOM at MAX_X_WINDOW=3600 | MAX_SAMPLE_RATE=1000Hz assumed, 259MB pre-alloc | Reduced to 200Hz, TieredBuffer replaces flat ring |
## Next Phase Readiness
**Ready:**
- Library stable at v0.5.0-SNAPSHOT, published to mavenLocal
- Remote git: `ssh://3nt-git.duckdns.org:222/davide.trentin/KMPCharts.git`
- AndroidChartsApp consuming library, builds clean
- Clean Architecture sensor domain in place
**Potential next work:**
- Dirty flag render (skip frame if no new data) — thermal/battery improvement
- targetFps=30 default — thermal improvement
- iOS expect/actual for currentTimeMs (currently Android only)
- Publish to Maven Central or GitHub Packages
- KDoc on public API surface
- README with integration example
**Deferred:**
- iOS support (expect/actual beyond currentTimeMs)
- Adaptive sensor rate based on xWindowSeconds
---
*Phase: v0.2.0-polish, completed 2026-05-21*

View file

@ -7,6 +7,8 @@ KMP real-time line chart for Android. Renders multiple sensor signals at 200Hz+
- Multiple signals on one chart, added/removed dynamically - Multiple signals on one chart, added/removed dynamically
- FIFO circular buffer — lock-free writes at 200Hz+, safe render reads at 60fps - 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) - 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 - X axis: scrolling window, seconds from configurable t0
- Y axis: auto-fit or fixed range - Y axis: auto-fit or fixed range
- Material3 default theme, fully customizable via `ChartTheme` - Material3 default theme, fully customizable via `ChartTheme`
@ -25,7 +27,7 @@ Or Maven local:
```kotlin ```kotlin
// build.gradle.kts // build.gradle.kts
implementation("eu.henesis:chart-realtime:0.1.0-SNAPSHOT") implementation("dev.dtrentin:chart-realtime:0.5.0-SNAPSHOT")
``` ```
## Quick start (3 lines) ## Quick start (3 lines)
@ -46,7 +48,7 @@ ChartConfig(
xWindowSeconds = 10f, // visible window width xWindowSeconds = 10f, // visible window width
yRange = YRange.Auto(), // or YRange.Fixed(-1f, 1f) yRange = YRange.Auto(), // or YRange.Fixed(-1f, 1f)
t0 = T0.FirstSample, // or T0.Fixed(epochMs) t0 = T0.FirstSample, // or T0.Fixed(epochMs)
targetFps = null, // null = display refresh rate targetFps = 30, // default 30 fps; null = max display refresh rate
maxBufferSeconds = 3600f, // 1h history per signal maxBufferSeconds = 3600f, // 1h history per signal
theme = ChartTheme( // optional visual override theme = ChartTheme( // optional visual override
backgroundColor = Color(0xFF1A1A2E), backgroundColor = Color(0xFF1A1A2E),
@ -56,6 +58,30 @@ ChartConfig(
) )
``` ```
## 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 ## Flow API
```kotlin ```kotlin
@ -68,10 +94,11 @@ state.collectFrom("ecg", ecgFlow, viewModelScope) // ecgFlow emits (timestampMs
## Performance notes ## Performance notes
- Data thread pushes at any rate; render thread reads at display refresh (or `targetFps`) - Data thread pushes at any rate; render thread reads at `targetFps` (default 30) or display refresh if null
- Buffer sized to `maxBufferSeconds × 1000 samples/s` per signal - Buffer sized to `maxBufferSeconds × 1000 samples/s` per signal
- LoD decimation reduces >pixel-count data to min/max per pixel column - 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`) - 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 ## Requirements

View file

@ -21,6 +21,10 @@ kotlin {
publishLibraryVariants("release") publishLibraryVariants("release")
} }
iosX64()
iosArm64()
iosSimulatorArm64()
sourceSets { sourceSets {
commonMain.dependencies { commonMain.dependencies {
implementation(compose.runtime) implementation(compose.runtime)

View file

@ -4,6 +4,9 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@ -19,6 +22,14 @@ import dev.dtrentin.chart.render.AxisRenderer.resolveYRange
import dev.dtrentin.chart.render.SignalRenderer.drawSignal import dev.dtrentin.chart.render.SignalRenderer.drawSignal
import dev.dtrentin.chart.render.rememberFrameTick import dev.dtrentin.chart.render.rememberFrameTick
/**
* Renders all signals held by [state] on a Canvas that repaints at [ChartConfig.targetFps].
*
* @param state holds all signal data and config.
* @param modifier applied to Canvas.
* @param xWindowSeconds visible X window in seconds; overrides [state.config.xWindowSeconds] at call site.
* @param theme visual theme; overrides [state.config.theme] at call site.
*/
@Composable @Composable
fun RealtimeChart( fun RealtimeChart(
state: RealtimeChartState, state: RealtimeChartState,
@ -36,6 +47,7 @@ fun RealtimeChart(
val chartBottomInsetPx = remember(config.xLabelMode) { val chartBottomInsetPx = remember(config.xLabelMode) {
if (config.xLabelMode == AxisLabelMode.BESIDE) with(density) { 20.dp.toPx() } else 0f if (config.xLabelMode == AxisLabelMode.BESIDE) with(density) { 20.dp.toPx() } else 0f
} }
var lastRenderedVersion by remember { mutableLongStateOf(-1L) }
val snapTs = remember { LongArray(TieredBuffer.TOTAL_CAPACITY) } val snapTs = remember { LongArray(TieredBuffer.TOTAL_CAPACITY) }
val snapV = remember { FloatArray(TieredBuffer.TOTAL_CAPACITY) } val snapV = remember { FloatArray(TieredBuffer.TOTAL_CAPACITY) }
val lodX = remember { FloatArray(TieredBuffer.TOTAL_CAPACITY) } val lodX = remember { FloatArray(TieredBuffer.TOTAL_CAPACITY) }
@ -45,6 +57,8 @@ fun RealtimeChart(
Canvas(modifier = modifier.background(theme.backgroundColor)) { Canvas(modifier = modifier.background(theme.backgroundColor)) {
@Suppress("UNUSED_EXPRESSION") frameTick @Suppress("UNUSED_EXPRESSION") frameTick
val currentVersion = state.dataVersion
if (currentVersion == lastRenderedVersion) return@Canvas
val signals = state.signals val signals = state.signals
if (signals.isEmpty() || state.resolvedT0Ms == null) return@Canvas if (signals.isEmpty() || state.resolvedT0Ms == null) return@Canvas
val windowMs = (xWindowSeconds * 1000f).toLong() val windowMs = (xWindowSeconds * 1000f).toLong()
@ -86,5 +100,6 @@ fun RealtimeChart(
lodDecimator = lodDecimator, mode = config.lodMode, lodDecimator = lodDecimator, mode = config.lodMode,
) )
} }
lastRenderedVersion = currentVersion
} }
} }

View file

@ -27,6 +27,10 @@ class RealtimeChartState(
private val _signals = mutableStateOf<Map<String, SignalEntry>>(emptyMap()) private val _signals = mutableStateOf<Map<String, SignalEntry>>(emptyMap())
internal val signals: Map<String, SignalEntry> get() = _signals.value internal val signals: Map<String, SignalEntry> get() = _signals.value
/** Increments on every [push] call. Use to skip rendering when no new data has arrived. */
@Volatile var dataVersion: Long = 0L
private set
@Volatile internal var resolvedT0Ms: Long? = when (val t = config.t0) { @Volatile internal var resolvedT0Ms: Long? = when (val t = config.t0) {
is T0.Fixed -> t.epochMs is T0.Fixed -> t.epochMs
T0.FirstSample -> null T0.FirstSample -> null
@ -46,6 +50,7 @@ class RealtimeChartState(
fun push(name: String, timestampMs: Long, value: Float) { fun push(name: String, timestampMs: Long, value: Float) {
if (resolvedT0Ms == null) resolvedT0Ms = timestampMs if (resolvedT0Ms == null) resolvedT0Ms = timestampMs
_signals.value[name]?.buffer?.push(timestampMs, value) _signals.value[name]?.buffer?.push(timestampMs, value)
dataVersion++
} }
/** Collects (timestampMs, value) pairs from [flow] into signal [name]. Returns [Job]. */ /** Collects (timestampMs, value) pairs from [flow] into signal [name]. Returns [Job]. */

View file

@ -1,7 +1,13 @@
package dev.dtrentin.chart.model package dev.dtrentin.chart.model
/** Controls where axis tick labels are drawn. */
enum class AxisLabelMode { enum class AxisLabelMode {
INSIDE, // labels drawn over chart data area (default) /** Labels drawn over chart data area. No margin reserved. */
BESIDE, // labels in reserved margin outside chart data area INSIDE,
HIDDEN, // no labels
/** Labels in reserved margin outside chart area. Inset applied automatically. */
BESIDE,
/** No axis labels rendered. */
HIDDEN,
} }

View file

@ -6,7 +6,7 @@ package dev.dtrentin.chart.model
* @param xWindowSeconds visible X range in seconds. Data older than (latestTs - xWindowSeconds) is not rendered. * @param xWindowSeconds visible X range in seconds. Data older than (latestTs - xWindowSeconds) is not rendered.
* @param yRange Y axis range strategy. * @param yRange Y axis range strategy.
* @param t0 Origin of X axis (seconds = 0). * @param t0 Origin of X axis (seconds = 0).
* @param targetFps Render rate cap. null = display refresh rate via withFrameNanos. Range: 1..displayMax. * @param targetFps Render rate cap. Default 30 fps. Pass null to render at display refresh rate (use for signals display Hz). Range: 1..displayMax.
* @param maxBufferSeconds Max history kept per signal. Caller is responsible for memory implications. * @param maxBufferSeconds Max history kept per signal. Caller is responsible for memory implications.
* @param theme Visual theme (colors, stroke widths). * @param theme Visual theme (colors, stroke widths).
*/ */
@ -14,7 +14,7 @@ data class ChartConfig(
val xWindowSeconds: Float = 10f, val xWindowSeconds: Float = 10f,
val yRange: YRange = YRange.Auto(), val yRange: YRange = YRange.Auto(),
val t0: T0 = T0.FirstSample, val t0: T0 = T0.FirstSample,
val targetFps: Int? = null, val targetFps: Int? = 30,
val maxBufferSeconds: Float = 60f, val maxBufferSeconds: Float = 60f,
val theme: ChartTheme = ChartTheme(), val theme: ChartTheme = ChartTheme(),
val showGrid: Boolean = true, val showGrid: Boolean = true,

View file

@ -1,3 +1,10 @@
package dev.dtrentin.chart.model package dev.dtrentin.chart.model
enum class LodMode { MIN_MAX, LTTB } /** Level-of-Detail decimation strategy applied when visible samples exceed pixel columns. */
enum class LodMode {
/** Retain min and max per pixel column. Preserves signal peaks. Zero allocations at render time. */
MIN_MAX,
/** Largest-Triangle-Three-Buckets. Visually accurate downsampling. Slight allocation per render frame. */
LTTB,
}

View file

@ -0,0 +1,149 @@
package dev.dtrentin.chart
import androidx.compose.ui.graphics.Color
import dev.dtrentin.chart.model.ChartConfig
import dev.dtrentin.chart.model.SignalConfig
import dev.dtrentin.chart.model.T0
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class RealtimeChartStateTest {
private val defaultSignalConfig = SignalConfig(color = Color.Red)
// ── addSignal ──────────────────────────────────────────────────────────────
@Test fun addSignal_signalPresent() {
val state = RealtimeChartState()
state.addSignal("s1", defaultSignalConfig)
assertNotNull(state.signals["s1"])
}
@Test fun addSignal_multipleSignals_allPresent() {
val state = RealtimeChartState()
state.addSignal("a", defaultSignalConfig)
state.addSignal("b", SignalConfig(color = Color.Blue))
assertEquals(2, state.signals.size)
assertNotNull(state.signals["a"])
assertNotNull(state.signals["b"])
}
@Test fun addSignal_duplicate_replacesBuffer() {
val state = RealtimeChartState()
state.addSignal("s1", defaultSignalConfig)
state.push("s1", 1000L, 1f)
// replace — old buffer gone
state.addSignal("s1", defaultSignalConfig)
// new buffer is empty: dataVersion still incremented by the push above, but
// the new entry should be independent; just assert the signal is still present
assertNotNull(state.signals["s1"])
assertEquals(1, state.signals.size)
}
// ── removeSignal ───────────────────────────────────────────────────────────
@Test fun removeSignal_signalAbsent() {
val state = RealtimeChartState()
state.addSignal("s1", defaultSignalConfig)
state.removeSignal("s1")
assertTrue(state.signals.isEmpty())
}
@Test fun removeSignal_noOp_whenMissing() {
val state = RealtimeChartState()
state.removeSignal("nonexistent") // must not throw
assertTrue(state.signals.isEmpty())
}
@Test fun removeSignal_onlyTargetRemoved() {
val state = RealtimeChartState()
state.addSignal("a", defaultSignalConfig)
state.addSignal("b", defaultSignalConfig)
state.removeSignal("a")
assertNull(state.signals["a"])
assertNotNull(state.signals["b"])
}
// ── push ───────────────────────────────────────────────────────────────────
@Test fun push_unknownSignal_noThrow() {
val state = RealtimeChartState()
state.push("ghost", 1000L, 42f) // no signal registered — must not throw
}
@Test fun push_incrementsDataVersion() {
val state = RealtimeChartState()
state.addSignal("s1", defaultSignalConfig)
val before = state.dataVersion
state.push("s1", 1000L, 1f)
assertEquals(before + 1L, state.dataVersion)
}
@Test fun push_multipleIncrements_eachPushBumpsVersion() {
val state = RealtimeChartState()
state.addSignal("s1", defaultSignalConfig)
repeat(5) { i -> state.push("s1", (1000L + i), i.toFloat()) }
assertEquals(5L, state.dataVersion)
}
@Test fun push_unknownSignal_stillIncrementsDataVersion() {
val state = RealtimeChartState()
// no addSignal — push on ghost still bumps dataVersion
state.push("ghost", 1000L, 1f)
assertEquals(1L, state.dataVersion)
}
// ── resolvedT0Ms / T0 ─────────────────────────────────────────────────────
@Test fun t0FirstSample_resolvedT0Ms_nullBeforePush() {
val state = RealtimeChartState(ChartConfig(t0 = T0.FirstSample))
assertNull(state.resolvedT0Ms)
}
@Test fun t0FirstSample_resolvedT0Ms_setOnFirstPush() {
val state = RealtimeChartState(ChartConfig(t0 = T0.FirstSample))
state.addSignal("s1", defaultSignalConfig)
state.push("s1", 5000L, 1f)
assertEquals(5000L, state.resolvedT0Ms)
}
@Test fun t0FirstSample_resolvedT0Ms_notChangedBySubsequentPush() {
val state = RealtimeChartState(ChartConfig(t0 = T0.FirstSample))
state.addSignal("s1", defaultSignalConfig)
state.push("s1", 5000L, 1f)
state.push("s1", 9000L, 2f)
assertEquals(5000L, state.resolvedT0Ms)
}
@Test fun t0Fixed_resolvedT0Ms_setFromConfig() {
val epoch = 1_000_000L
val state = RealtimeChartState(ChartConfig(t0 = T0.Fixed(epoch)))
assertEquals(epoch, state.resolvedT0Ms)
}
@Test fun t0Fixed_resolvedT0Ms_unchangedAfterPush() {
val epoch = 1_000_000L
val state = RealtimeChartState(ChartConfig(t0 = T0.Fixed(epoch)))
state.addSignal("s1", defaultSignalConfig)
state.push("s1", epoch + 1000L, 3f)
assertEquals(epoch, state.resolvedT0Ms)
}
// ── config ─────────────────────────────────────────────────────────────────
@Test fun config_exposedCorrectly() {
val cfg = ChartConfig(xWindowSeconds = 30f)
val state = RealtimeChartState(cfg)
assertEquals(cfg, state.config)
}
// ── dataVersion starts at 0 ────────────────────────────────────────────────
@Test fun dataVersion_initiallyZero() {
val state = RealtimeChartState()
assertEquals(0L, state.dataVersion)
}
}

View file

@ -0,0 +1,5 @@
package dev.dtrentin.chart
import platform.Foundation.NSDate
internal actual fun currentTimeMs(): Long = (NSDate.date().timeIntervalSince1970 * 1000).toLong()