From 73bbd9ddec5591b4d043f1764c48f4482ee4b373 Mon Sep 17 00:00:00 2001 From: dtrentin Date: Thu, 21 May 2026 11:11:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(chart-realtime):=20v0.3.0=20=E2=80=94=20di?= =?UTF-8?q?rty=20flag=20render,=20targetFps=3D30,=20iOS=20targets,=20KDoc,?= =?UTF-8?q?=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .paul/ROADMAP.md | 179 ++++++------------ .paul/STATE.md | 50 +++-- .paul/phases/v0.2.0-plan-SUMMARY.md | 159 ++++++++++++++++ chart-realtime/README.md | 33 +++- chart-realtime/build.gradle.kts | 4 + .../dev/dtrentin/chart/RealtimeChart.kt | 15 ++ .../dev/dtrentin/chart/RealtimeChartState.kt | 5 + .../dev/dtrentin/chart/model/AxisLabelMode.kt | 12 +- .../dev/dtrentin/chart/model/ChartConfig.kt | 4 +- .../dev/dtrentin/chart/model/LodMode.kt | 9 +- .../dtrentin/chart/RealtimeChartStateTest.kt | 149 +++++++++++++++ .../kotlin/dev/dtrentin/chart/Platform.ios.kt | 5 + 12 files changed, 479 insertions(+), 145 deletions(-) create mode 100644 .paul/phases/v0.2.0-plan-SUMMARY.md create mode 100644 chart-realtime/src/commonTest/kotlin/dev/dtrentin/chart/RealtimeChartStateTest.kt create mode 100644 chart-realtime/src/iosMain/kotlin/dev/dtrentin/chart/Platform.ios.kt diff --git a/.paul/ROADMAP.md b/.paul/ROADMAP.md index 674d0ac..a1dfbff 100644 --- a/.paul/ROADMAP.md +++ b/.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* diff --git a/.paul/STATE.md b/.paul/STATE.md index 9679ede..a41b849 100644 --- a/.paul/STATE.md +++ b/.paul/STATE.md @@ -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* diff --git a/.paul/phases/v0.2.0-plan-SUMMARY.md b/.paul/phases/v0.2.0-plan-SUMMARY.md new file mode 100644 index 0000000..3523e80 --- /dev/null +++ b/.paul/phases/v0.2.0-plan-SUMMARY.md @@ -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* diff --git a/chart-realtime/README.md b/chart-realtime/README.md index d04b235..0a2932d 100644 --- a/chart-realtime/README.md +++ b/chart-realtime/README.md @@ -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 diff --git a/chart-realtime/build.gradle.kts b/chart-realtime/build.gradle.kts index 4a35215..b3e8003 100644 --- a/chart-realtime/build.gradle.kts +++ b/chart-realtime/build.gradle.kts @@ -21,6 +21,10 @@ kotlin { publishLibraryVariants("release") } + iosX64() + iosArm64() + iosSimulatorArm64() + sourceSets { commonMain.dependencies { implementation(compose.runtime) diff --git a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChart.kt b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChart.kt index 580f0ac..f3bc149 100644 --- a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChart.kt +++ b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChart.kt @@ -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 } } diff --git a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChartState.kt b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChartState.kt index 8e0b333..94cfba4 100644 --- a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChartState.kt +++ b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChartState.kt @@ -27,6 +27,10 @@ class RealtimeChartState( private val _signals = mutableStateOf>(emptyMap()) internal val signals: Map 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]. */ diff --git a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/AxisLabelMode.kt b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/AxisLabelMode.kt index 8a34287..09b9a02 100644 --- a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/AxisLabelMode.kt +++ b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/AxisLabelMode.kt @@ -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, } diff --git a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/ChartConfig.kt b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/ChartConfig.kt index 5dcca9d..d19e55a 100644 --- a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/ChartConfig.kt +++ b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/ChartConfig.kt @@ -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, diff --git a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/LodMode.kt b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/LodMode.kt index 10f777e..b6f4bcb 100644 --- a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/LodMode.kt +++ b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/LodMode.kt @@ -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, +} diff --git a/chart-realtime/src/commonTest/kotlin/dev/dtrentin/chart/RealtimeChartStateTest.kt b/chart-realtime/src/commonTest/kotlin/dev/dtrentin/chart/RealtimeChartStateTest.kt new file mode 100644 index 0000000..b1fdf8b --- /dev/null +++ b/chart-realtime/src/commonTest/kotlin/dev/dtrentin/chart/RealtimeChartStateTest.kt @@ -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) + } +} diff --git a/chart-realtime/src/iosMain/kotlin/dev/dtrentin/chart/Platform.ios.kt b/chart-realtime/src/iosMain/kotlin/dev/dtrentin/chart/Platform.ios.kt new file mode 100644 index 0000000..e6c89c0 --- /dev/null +++ b/chart-realtime/src/iosMain/kotlin/dev/dtrentin/chart/Platform.ios.kt @@ -0,0 +1,5 @@ +package dev.dtrentin.chart + +import platform.Foundation.NSDate + +internal actual fun currentTimeMs(): Long = (NSDate.date().timeIntervalSince1970 * 1000).toLong()