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:
parent
99f7ee1252
commit
73bbd9ddec
12 changed files with 479 additions and 145 deletions
179
.paul/ROADMAP.md
179
.paul/ROADMAP.md
|
|
@ -2,123 +2,70 @@
|
|||
|
||||
## 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**
|
||||
Status: Not started
|
||||
Phases: 0 of 6 complete
|
||||
|
||||
## Phases
|
||||
|
||||
| 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
|
||||
| Milestone | Version | Status | Completed |
|
||||
|-----------|---------|--------|-----------|
|
||||
| v0.1.0 MVP | 0.1.0 | ✓ Shipped | 2026-05-20 |
|
||||
| v0.2.0 Polish | 0.5.0-SNAPSHOT | ✓ Shipped | 2026-05-21 |
|
||||
| v0.3.0 (TBD) | - | Not started | - |
|
||||
|
||||
---
|
||||
*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*
|
||||
|
|
|
|||
|
|
@ -2,28 +2,28 @@
|
|||
|
||||
## 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
|
||||
**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
|
||||
|
||||
Milestone: v0.2.0
|
||||
Phase: 3 of 4 — In Progress
|
||||
Milestone: v0.2.0-polish
|
||||
Phase: 6 of 6 — Complete
|
||||
Plan: .paul/phases/v0.2.0-plan.md
|
||||
Status: In Progress
|
||||
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.
|
||||
Status: Complete
|
||||
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:
|
||||
- Milestone: [██████████] 100%
|
||||
- Phase 6: [██████████] 100%
|
||||
- v0.1.0 milestone: [██████████] 100% SHIPPED
|
||||
- v0.2.0 milestone: [██████████] 100% SHIPPED
|
||||
|
||||
## Loop Position
|
||||
|
||||
```
|
||||
PLAN ──▶ APPLY ──▶ UNIFY
|
||||
✓ ✓ ✓ [v0.1.0 SHIPPED]
|
||||
✓ ✓ ✓ [v0.2.0 SHIPPED — v0.5.0-SNAPSHOT]
|
||||
```
|
||||
|
||||
## Accumulated Context
|
||||
|
|
@ -34,28 +34,38 @@ PLAN ──▶ APPLY ──▶ UNIFY
|
|||
|----------|-------|--------|
|
||||
| Canvas custom rendering (no lib) | Init | Core architecture |
|
||||
| Render/data decoupled withFrameNanos | Init | RenderLoop design |
|
||||
| LoD min/max decimation | Init | SignalRenderer calls LodDecimator |
|
||||
| compose.foundation added to commonMain deps | Phase 4 | Canvas + Modifier.background require it |
|
||||
| iOS deferred | Init | commonMain only, no expect/actual beyond currentTimeMs |
|
||||
| TieredBuffer over flat ring | v0.2.0 | O(N×constant) memory, 20+ signals |
|
||||
| MIN_MAX tier binning (not MEAN) | v0.2.0 | Composable, no artifacts at tier boundaries |
|
||||
| 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
|
||||
|
||||
| Issue | Origin | Effort | Revisit |
|
||||
|-------|--------|--------|---------|
|
||||
| LodDecimator scratch array allocation per frame | Phase 2 | M | v0.2.0 |
|
||||
| Double snapshot per frame in RealtimeChart | Phase 4 | M | v0.2.0 |
|
||||
| Axis text labels | Phase 4 | M | v0.2.0 — needs TextMeasurer |
|
||||
| iOS support | Init | L | Future milestone |
|
||||
| ~~Dirty flag render (skip frame if no new data)~~ | v0.2.0 | S | ✓ v0.3.0 |
|
||||
| ~~targetFps=30 default~~ | v0.2.0 | S | ✓ v0.3.0 |
|
||||
| ~~iOS expect/actual beyond currentTimeMs~~ | Init | L | ✓ v0.3.0 |
|
||||
| ~~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
|
||||
None.
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-05-20
|
||||
Stopped at: Phases 1-5 complete. 16 tests pass.
|
||||
Next action: Phase 6 — 06-01 ChartTheme + Material3, 06-02 KDoc + README + publish config
|
||||
Resume context: All source in chart-realtime/commonMain; demo app in app/
|
||||
Last session: 2026-05-21
|
||||
Stopped at: v0.2.0 complete. All AC pass. Git pushed to remote.
|
||||
Next action: Define v0.3.0 scope (dirty flag render, targetFps=30, KDoc, publish)
|
||||
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*
|
||||
|
|
|
|||
159
.paul/phases/v0.2.0-plan-SUMMARY.md
Normal file
159
.paul/phases/v0.2.0-plan-SUMMARY.md
Normal 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 2–3600s, 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*
|
||||
|
|
@ -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
|
||||
- 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`
|
||||
|
|
@ -25,7 +27,7 @@ Or Maven local:
|
|||
|
||||
```kotlin
|
||||
// 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)
|
||||
|
|
@ -46,7 +48,7 @@ ChartConfig(
|
|||
xWindowSeconds = 10f, // visible window width
|
||||
yRange = YRange.Auto(), // or YRange.Fixed(-1f, 1f)
|
||||
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
|
||||
theme = ChartTheme( // optional visual override
|
||||
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
|
||||
|
||||
```kotlin
|
||||
|
|
@ -68,10 +94,11 @@ state.collectFrom("ecg", ecgFlow, viewModelScope) // ecgFlow emits (timestampMs
|
|||
|
||||
## 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
|
||||
- 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
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ kotlin {
|
|||
publishLibraryVariants("release")
|
||||
}
|
||||
|
||||
iosX64()
|
||||
iosArm64()
|
||||
iosSimulatorArm64()
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(compose.runtime)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import androidx.compose.foundation.Canvas
|
|||
import androidx.compose.foundation.background
|
||||
import androidx.compose.runtime.Composable
|
||||
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.graphics.Path
|
||||
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.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
|
||||
fun RealtimeChart(
|
||||
state: RealtimeChartState,
|
||||
|
|
@ -36,6 +47,7 @@ fun RealtimeChart(
|
|||
val chartBottomInsetPx = remember(config.xLabelMode) {
|
||||
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 snapV = remember { FloatArray(TieredBuffer.TOTAL_CAPACITY) }
|
||||
val lodX = remember { FloatArray(TieredBuffer.TOTAL_CAPACITY) }
|
||||
|
|
@ -45,6 +57,8 @@ fun RealtimeChart(
|
|||
|
||||
Canvas(modifier = modifier.background(theme.backgroundColor)) {
|
||||
@Suppress("UNUSED_EXPRESSION") frameTick
|
||||
val currentVersion = state.dataVersion
|
||||
if (currentVersion == lastRenderedVersion) return@Canvas
|
||||
val signals = state.signals
|
||||
if (signals.isEmpty() || state.resolvedT0Ms == null) return@Canvas
|
||||
val windowMs = (xWindowSeconds * 1000f).toLong()
|
||||
|
|
@ -86,5 +100,6 @@ fun RealtimeChart(
|
|||
lodDecimator = lodDecimator, mode = config.lodMode,
|
||||
)
|
||||
}
|
||||
lastRenderedVersion = currentVersion
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@ class RealtimeChartState(
|
|||
private val _signals = mutableStateOf<Map<String, SignalEntry>>(emptyMap())
|
||||
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) {
|
||||
is T0.Fixed -> t.epochMs
|
||||
T0.FirstSample -> null
|
||||
|
|
@ -46,6 +50,7 @@ class RealtimeChartState(
|
|||
fun push(name: String, timestampMs: Long, value: Float) {
|
||||
if (resolvedT0Ms == null) resolvedT0Ms = timestampMs
|
||||
_signals.value[name]?.buffer?.push(timestampMs, value)
|
||||
dataVersion++
|
||||
}
|
||||
|
||||
/** Collects (timestampMs, value) pairs from [flow] into signal [name]. Returns [Job]. */
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
package dev.dtrentin.chart.model
|
||||
|
||||
/** Controls where axis tick labels are drawn. */
|
||||
enum class AxisLabelMode {
|
||||
INSIDE, // labels drawn over chart data area (default)
|
||||
BESIDE, // labels in reserved margin outside chart data area
|
||||
HIDDEN, // no labels
|
||||
/** Labels drawn over chart data area. No margin reserved. */
|
||||
INSIDE,
|
||||
|
||||
/** Labels in reserved margin outside chart area. Inset applied automatically. */
|
||||
BESIDE,
|
||||
|
||||
/** No axis labels rendered. */
|
||||
HIDDEN,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 yRange Y axis range strategy.
|
||||
* @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 theme Visual theme (colors, stroke widths).
|
||||
*/
|
||||
|
|
@ -14,7 +14,7 @@ data class ChartConfig(
|
|||
val xWindowSeconds: Float = 10f,
|
||||
val yRange: YRange = YRange.Auto(),
|
||||
val t0: T0 = T0.FirstSample,
|
||||
val targetFps: Int? = null,
|
||||
val targetFps: Int? = 30,
|
||||
val maxBufferSeconds: Float = 60f,
|
||||
val theme: ChartTheme = ChartTheme(),
|
||||
val showGrid: Boolean = true,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,10 @@
|
|||
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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package dev.dtrentin.chart
|
||||
|
||||
import platform.Foundation.NSDate
|
||||
|
||||
internal actual fun currentTimeMs(): Long = (NSDate.date().timeIntervalSince1970 * 1000).toLong()
|
||||
Loading…
Add table
Reference in a new issue