From 07197ed8df0d0330b207bd18404ae148679da01b Mon Sep 17 00:00:00 2001 From: Trentin Davide Date: Fri, 22 May 2026 10:54:26 +0200 Subject: [PATCH] =?UTF-8?q?chore(portfolio):=20v1.0.0=20prep=20=E2=80=94?= =?UTF-8?q?=20docs,=20CI,=20demos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Autonomous portfolio prep batch. All gates green: 207 tests pass on iosSimulatorArm64Test, assembleRelease green, apiCheck green, app builds. Docs: - CHANGELOG.md (Keep-a-Changelog, retroactive v0.1-v0.5) - CONTRIBUTING.md (build + style + PR workflow + perf rules) - chart-realtime/README.md rewrite for v0.5.0 API surface - docs/PERFORMANCE.md (LoD perf table, alloc budget, memory budget, stability report, threading model) CI (.github/): - workflows/ci.yml — macos-latest, JDK 21, konan cache, full gate - workflows/release.yml — tag-triggered, GitHub release with XCFramework + AAR; Maven Central as commented TODO (needs OSSRH + GPG secrets) - dependabot.yml — weekly gradle + actions updates - pull_request_template.md + 2 issue templates (bug, feature) Android demo polish: - DemoScreen + ControlPanel (Material3 surface) - Sample rate slider (10..200Hz), 1-6 signal count, per-signal waveform picker (sine/square/triangle/noise), window seconds, LoD picker, Y label mode, Y formatter, interaction toggle, theme switch, status chips - Single producer coroutine (cleaner crosshair readout, easier rate accounting) - SignalGenerator + WaveformType + DemoConfig + PushCounter iOS demo scaffold (samples/ios/ChartRealtimeDemo/): - SPM Package.swift with local binaryTarget pointing to chart-realtime/build/XCFrameworks/debug/ChartRealtime.xcframework - DemoApp.swift + DemoView.swift (smoke test confirming Kotlin types accessible from Swift) - README documents the ComposeUIViewController shim needed in chart-realtime/src/iosMain/ for full SwiftUI hosting (deferred to v1.0.0 T1) - xcodebuild against iOS Simulator builds green Debt: - gradle.properties: removed kotlin.internal.klibs.non-packed=false (Compose-MP 1.11.0 final no longer needs the bypass; apiCheck + native compile + 207 tests still green) - 8 remaining deprecation warnings tied to com.android.library + KMP plugin combo; clearing them requires migration to com.android.kotlin.multiplatform.library 9.2.0 which forces ABI baseline regen + Maven publish restructure + XCFramework hierarchy expansion. Deferred as dedicated ticket. Skipped (need user): - CODE_OF_CONDUCT.md (content classifier blocks Contributor Covenant verbatim) - Maven Central T1 (Sonatype + GPG) - Benchmark vs competitors T4 (methodology + Maven coords) - Logo/banner/GIF T7 (design assets) - Tag + GitHub release T10 (rename pending) Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/ISSUE_TEMPLATE/bug_report.md | 27 ++ .github/ISSUE_TEMPLATE/feature_request.md | 18 ++ .github/dependabot.yml | 25 ++ .github/pull_request_template.md | 19 ++ .github/workflows/ci.yml | 74 +++++ .github/workflows/release.yml | 73 +++++ .paul/STATE.md | 35 +- CHANGELOG.md | 181 +++++++++++ CONTRIBUTING.md | 155 +++++++++ .../dev/dtrentin/chart/demo/ControlPanel.kt | 303 ++++++++++++++++++ .../dev/dtrentin/chart/demo/DemoConfig.kt | 63 ++++ .../dev/dtrentin/chart/demo/DemoScreen.kt | 248 ++++++++++++++ .../dev/dtrentin/chart/demo/MainActivity.kt | 68 +--- .../dev/dtrentin/chart/demo/PushCounter.kt | 15 + .../dtrentin/chart/demo/SignalGenerator.kt | 51 +++ .../dev/dtrentin/chart/demo/WaveformType.kt | 35 ++ chart-realtime/README.md | 225 +++++++++---- docs/PERFORMANCE.md | 144 +++++++++ gradle.properties | 15 +- samples/ios/ChartRealtimeDemo/.gitignore | 5 + samples/ios/ChartRealtimeDemo/Package.swift | 26 ++ samples/ios/ChartRealtimeDemo/README.md | 118 +++++++ .../Sources/ChartRealtimeDemo/DemoApp.swift | 11 + .../Sources/ChartRealtimeDemo/DemoView.swift | 115 +++++++ 24 files changed, 1914 insertions(+), 135 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/dependabot.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 app/src/main/kotlin/dev/dtrentin/chart/demo/ControlPanel.kt create mode 100644 app/src/main/kotlin/dev/dtrentin/chart/demo/DemoConfig.kt create mode 100644 app/src/main/kotlin/dev/dtrentin/chart/demo/DemoScreen.kt create mode 100644 app/src/main/kotlin/dev/dtrentin/chart/demo/PushCounter.kt create mode 100644 app/src/main/kotlin/dev/dtrentin/chart/demo/SignalGenerator.kt create mode 100644 app/src/main/kotlin/dev/dtrentin/chart/demo/WaveformType.kt create mode 100644 docs/PERFORMANCE.md create mode 100644 samples/ios/ChartRealtimeDemo/.gitignore create mode 100644 samples/ios/ChartRealtimeDemo/Package.swift create mode 100644 samples/ios/ChartRealtimeDemo/README.md create mode 100644 samples/ios/ChartRealtimeDemo/Sources/ChartRealtimeDemo/DemoApp.swift create mode 100644 samples/ios/ChartRealtimeDemo/Sources/ChartRealtimeDemo/DemoView.swift diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..ae8a555 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,27 @@ +--- +name: Bug report +about: Reproducible issue with the library +title: '[bug] ' +labels: bug +--- + +## Affected version + + +## Platform +- [ ] Android (API level: , device: ) +- [ ] iOS (simulator/device, iOS version: ) + +## Reproducer +```kotlin +// smallest snippet that triggers the issue +``` + +## Expected behavior + +## Actual behavior + +## Logcat / Console output +``` + +``` diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..a28832f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,18 @@ +--- +name: Feature request +about: Propose a new feature +title: '[feat] ' +labels: enhancement +--- + +## Use case + + +## Why existing API doesn't solve it + +## Proposed acceptance criteria +- [ ] +- [ ] + +## Out of scope + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..7bd8fb1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,25 @@ +version: 2 +updates: + - package-ecosystem: "gradle" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "gradle" + commit-message: + prefix: "chore(deps)" + include: "scope" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 3 + labels: + - "dependencies" + - "actions" + commit-message: + prefix: "chore(ci)" + include: "scope" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..c30d501 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,19 @@ +## Summary + + +## Changes +- Module: +- Type: + +## Test plan +- [ ] `./gradlew :chart-realtime:iosSimulatorArm64Test` green +- [ ] `./gradlew :chart-realtime:assembleRelease` green +- [ ] `./gradlew :chart-realtime:apiCheck` green (or baseline regenerated + diff in PR body) +- [ ] `./gradlew :app:assembleDebug` green + +## ABI +- [ ] No public API change +- [ ] Intentional ABI change — `chart-realtime.api` / `chart-realtime.klib.api` updated in this PR + +## Performance + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d340254 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,74 @@ +name: CI + +on: + push: + branches: [master, main] + pull_request: + branches: [master, main] + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: macos-latest # macOS for combined Android + iOS build + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '21' + + - uses: gradle/actions/setup-gradle@v4 + with: + cache-read-only: ${{ github.ref != 'refs/heads/master' && github.ref != 'refs/heads/main' }} + + - name: Cache Kotlin/Native (konan) + uses: actions/cache@v4 + with: + path: ~/.konan + key: konan-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml', 'chart-realtime/build.gradle.kts') }} + restore-keys: konan-${{ runner.os }}- + + - name: Library — Android compile + run: ./gradlew :chart-realtime:compileReleaseKotlinAndroid --stacktrace + + - name: Library — iOS arm64 compile + run: ./gradlew :chart-realtime:compileKotlinIosArm64 --stacktrace + + - name: Library — iOS sim arm64 compile + run: ./gradlew :chart-realtime:compileKotlinIosSimulatorArm64 --stacktrace + + - name: Library — iOS sim tests (canonical gate) + run: ./gradlew :chart-realtime:iosSimulatorArm64Test --stacktrace + + - name: Library — assembleRelease + run: ./gradlew :chart-realtime:assembleRelease --stacktrace + + - name: ABI check + run: ./gradlew :chart-realtime:apiCheck --stacktrace + + - name: App — assembleDebug + run: ./gradlew :app:assembleDebug --stacktrace + + - name: Maven publish dry-run + run: ./gradlew :chart-realtime:publishToMavenLocal --stacktrace + + - name: Upload XCFramework artifact + if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main') + uses: actions/upload-artifact@v4 + with: + name: ChartRealtime-XCFramework + path: chart-realtime/build/XCFrameworks/release/ChartRealtime.xcframework + retention-days: 14 + + - name: Upload test report on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-report-${{ github.run_id }} + path: chart-realtime/build/reports/tests/ + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..258d1c8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,73 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: macos-latest + timeout-minutes: 45 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # tags + history for changelog + + - uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '21' + + - uses: gradle/actions/setup-gradle@v4 + + - name: Cache Kotlin/Native (konan) + uses: actions/cache@v4 + with: + path: ~/.konan + key: konan-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml', 'chart-realtime/build.gradle.kts') }} + restore-keys: konan-${{ runner.os }}- + + - name: Full test gate + run: ./gradlew :chart-realtime:iosSimulatorArm64Test :chart-realtime:apiCheck :app:assembleDebug --stacktrace + + - name: Build release artifacts + run: ./gradlew :chart-realtime:assembleRelease :chart-realtime:assembleChartRealtimeReleaseXCFramework --stacktrace + + - name: Zip XCFramework + run: | + cd chart-realtime/build/XCFrameworks/release + zip -r ChartRealtime.xcframework.zip ChartRealtime.xcframework + ls -lh ChartRealtime.xcframework.zip + + - name: Extract release notes from CHANGELOG + id: changelog + run: | + TAG_VERSION="${GITHUB_REF_NAME#v}" + awk -v ver="$TAG_VERSION" ' + $0 ~ "^## \\["ver"\\]" {flag=1; next} + flag && /^## \[/ {exit} + flag {print} + ' CHANGELOG.md > release-notes.md + echo "release-notes-path=release-notes.md" >> $GITHUB_OUTPUT + + - name: Create GitHub release + uses: softprops/action-gh-release@v2 + with: + body_path: release-notes.md + files: | + chart-realtime/build/XCFrameworks/release/ChartRealtime.xcframework.zip + chart-realtime/build/outputs/aar/chart-realtime-release.aar + draft: false + prerelease: ${{ contains(github.ref, '-SNAPSHOT') || contains(github.ref, '-rc') || contains(github.ref, '-beta') || contains(github.ref, '-alpha') }} + + # TODO: Maven Central publish (requires OSSRH_USERNAME, OSSRH_PASSWORD, GPG_SECRET_KEY, GPG_PASSPHRASE) + # - name: Publish to Maven Central + # env: + # ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.OSSRH_USERNAME }} + # ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.OSSRH_PASSWORD }} + # ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.GPG_SECRET_KEY }} + # ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.GPG_PASSPHRASE }} + # run: ./gradlew publishAndReleaseToMavenCentral --no-configuration-cache diff --git a/.paul/STATE.md b/.paul/STATE.md index 9b0af42..beb68c8 100644 --- a/.paul/STATE.md +++ b/.paul/STATE.md @@ -9,12 +9,20 @@ See: .paul/PROJECT.md (updated 2026-05-21) ## Current Position -Milestone: v0.5.0-architecture (SHIPPED ✓) -Phase: All 6 P-groups complete. ABI regenerated. Unify green. -Plan: .paul/phases/v0.5.0-architecture-plan.md (v2 deltas applied 2026-05-21) -Status: v0.5.0 closed. Next milestone v1.0.0 public release. -Last completed: v0.5.0 architecture + interaction (2026-05-21) -Last activity: 2026-05-21 — v0.5.0 SHIPPED via kmp-manager 6-phase flow. 14 agent calls. 13 plan tasks done (D1, T1-T10, D3, D4). 107 → 207 tests pass on iosSimulatorArm64 (+100). ABI baseline regen: 161/211 → 428/501 LOC. HARD BREAK on LodMode/LodDecimator/targetFps. Interaction layer (pinch/pan/crosshair) shipped. MinMaxLTTB SOTA 1.80× faster than pure LTTB. All toolchain bumps (Kotlin 2.3.21, Compose-MP 1.11.0, AGP 9.2.0, Gradle 9.5.1). kotlinx-datetime dropped (kotlin.time.Clock). iosX64 dropped (Compose-MP 1.11.0 incompat). +Milestone: v1.0.0-public-release (IN PROGRESS — autonomous tasks done, user-blocked tasks pending) +Phase: Portfolio prep autonomous batch shipped (debt partial, docs, CI, demos). User-side tasks pending. +Plan: .paul/phases/v1.0.0-public-release-plan.md +Status: 5/10 plan tasks done (T2, T3, T5, T6, T8 partial + T9 perf docs + partial debt). 5 blocked on user (T1 Maven Central, T4 benchmark vs competitors, T7 assets, T10 tag/release, app rename). +Last completed: v1.0.0 portfolio prep autonomous batch (2026-05-22) +Last activity: 2026-05-22 — Autonomous portfolio prep batch. 1 debt warning cleared (klib non-packed compat removed; 8 remain blocked behind com.android.kotlin.multiplatform.library migration). Docs: CHANGELOG.md, CONTRIBUTING.md, chart-realtime/README.md rewrite, docs/PERFORMANCE.md. CI: .github/workflows/{ci,release}.yml + dependabot + PR template + 2 issue templates. Android demo (T8) polished: Material3 control panel (sample rate slider, signal count, waveform per signal, window seconds, LoD picker, axis label mode, formatter picker, interaction toggle, theme switch, status row). iOS SwiftUI demo (T3 scaffold) via SPM Package.swift + sources; xcodebuild builds green; smoke test confirms XCFramework consumable from Swift; ComposeUIViewController shim deferred to v1.0.0 T1. + +Progress: +- v0.1.0 milestone: [██████████] 100% SHIPPED +- v0.2.0 milestone: [██████████] 100% SHIPPED +- v0.3.0 milestone: [██████████] 100% SHIPPED +- v0.4.0 milestone: [██████████] 100% SHIPPED +- v0.5.0 milestone: [██████████] 100% SHIPPED +- v1.0.0 milestone: [█████░░░░░] 50% portfolio prep done, user-blocked items pending Progress: - v0.1.0 milestone: [██████████] 100% SHIPPED @@ -27,9 +35,22 @@ Progress: ``` PLAN ──▶ APPLY ──▶ UNIFY - ✓ ✓ ✓ [v0.5.0 architecture + interaction SHIPPED — v0.5.0-SNAPSHOT] + ✓ ◐ ✓ [v1.0.0 partial — autonomous batch done, user-blocked items pending] ``` +## v1.0.0 deferred (need user) + +- **T1 Maven Central publish** — Sonatype OSSRH account + GPG key + DNS TXT proof for `dev.dtrentin` namespace +- **T4 Benchmark vs competitors** — methodology decision (JMH? androidx.benchmark? both?), Maven coords for Vico/KoalaPlot/MPAndroidChart, hardware target (Pixel 6 / iPhone 13 vs CI runners) +- **T7 Logo / banner / GIF** — design assets (manager cannot generate raster/vector art) +- **T10 Tag + release + push to GitHub** — user wants to wait on GitHub (potential project rename). Currently remote = ssh://3nt-git.duckdns.org:222/davide.trentin/KMPCharts.git +- **App rename** — user mentioned considering rename; deferred +- **ComposeUIViewController shim** (T1 dependency) — small Kotlin shim in `chart-realtime/src/iosMain/` exporting `RealtimeChartViewController(state, xWindowSeconds): UIViewController` to enable real SwiftUI demo + +## v1.0.0 deferred (out-of-scope debt) + +- **Remaining 8 deprecation warnings** (KGP 2.3 / AGP 9 `:app` plugin + `libraryVariants` API + AGP-newDsl) blocked behind migration to `com.android.kotlin.multiplatform.library` 9.2.0. Migration forces ABI baseline regen + restructured Maven publication graph + XCFramework hierarchy expansion. Deferred as dedicated post-1.0.0 ticket. + ## Accumulated Context ### Decisions diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..aebe28e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,181 @@ +# Changelog + +All notable changes to `chart-realtime` documented here. + +Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +Project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) from `1.0.0` onward. + +Pre-`1.0.0` releases share the `0.5.0-SNAPSHOT` Maven coordinate per +`group:dev.dtrentin`, `artifact:chart-realtime`. Stable coordinates and Maven +Central publishing land in `1.0.0`. + +## [Unreleased] + +### Changed +- KGP 2.3 / AGP 9 deprecation warnings cleanup (in progress) + +## [0.5.0] — 2026-05-22 — Architecture + Interaction + +### Added +- **Interaction layer (T7)** — pinch zoom, drag pan, tap crosshair via `ChartInteractionState` + + `rememberChartInteractionState()`. `ViewportMode` sealed (`Following` / `Frozen` / + `History(anchorMs)`). Swipe-to-right-edge resumes `Following`. `InverseProjection` + helper: pixel → ms + bisect nearest-sample +- **`LodStrategy` interface (T2)** + three default impls in `lod/` package: + - `MinMaxLodStrategy` — per-pixel-column min+max preservation + - `LttbLodStrategy` — Largest-Triangle-Three-Buckets + - `MinMaxLttbLodStrategy` — SOTA per [arXiv 2305.00332](https://arxiv.org/abs/2305.00332), + 1.80× faster than pure LTTB on Kotlin/Native scalar code (10× target claim assumes Rust + SIMD) +- **`SignalRenderer` public interface (T3)** + `LineSignalRenderer` default object. + Custom renderer impl-able in ~30 LOC. Primitive-only signature, no `SignalEntry` leak +- **`AxisFormatter` interface (T4)** + four default impls: `TimeAxisFormatter`, + `DecimalAxisFormatter`, `DateTimeAxisFormatter`, `UnitAxisFormatter` +- **`ChartConfig` split (T5)** — `DataConfig` + `AxisConfig` + `RenderConfig` + new + `FrameRate` sealed type (`Display` / `Fixed(fps)`) +- **`TieredBuffer.snapshotWindow` (T1)** — bisect-based windowed snapshot for 30× fewer + per-frame copies on small windows in large buffers +- **`RealtimeChartState.clear()` (T10)** — public purge API (preserves signal registrations) +- **`RealtimeChartState.signalsArray` (T9)** — cached array view of signals, invalidated + only by `addSignal` / `removeSignal` +- **`@Immutable` / `@Stable` annotations (T6)** on all public types — 0 unstable +- **`NumberFormat.formatFixed` overflow guard (D4)** — routes to scientific @ `|v|≥1e19` + +### Changed (BREAKING) +- **Removed `LodMode` enum** — replaced by `LodStrategy` interface +- **Removed `LodDecimator` internal class** — replaced by `LodStrategy` impls +- **Removed `ChartConfig.targetFps` field** — replaced by `RenderConfig.frameRate: FrameRate` +- **`ChartConfig` flat fields removed** — accessed via nested configs: `config.data.xWindowSeconds`, + `config.render.theme`, `config.axis.xLabelMode`, etc. Top-level inline accessors retained for + `xWindowSeconds` and `theme` +- **`SignalRenderer` signature changed** — internal object → public interface with + primitive-only params (`Color`, `Float`, `FloatArray`, `Path`) +- **`SignalConfig.renderer` field added** — per-signal renderer override, defaults to + `LineSignalRenderer` singleton +- **LTTB upper bound aligned to half-open semantic (D3)** — `[start, start+windowMs)` + matches `TieredBuffer.snapshot`. Samples at exact upper bound now excluded +- **`Pair` removed from `AxisRenderer.resolveYRange`** — replaced by + `FloatArray` out param + +### Performance +- LineSignalRenderer caches `Stroke` instances per strokeWidth (T8) +- AxisRenderer caches `TextStyle` instances (16-entry cap) +- Render path: 0 `Pair`/`Map`/`Iterator` allocations per frame at steady state + +### Toolchain +- Kotlin 2.1.0 → 2.3.21 +- Compose Multiplatform 1.8.0 → 1.11.0 +- Android Gradle Plugin 8.7.3 → 9.2.0 +- Gradle 8.11.1 → 9.5.1 +- coroutines 1.9.0 → 1.11.0; kotlin-test → 2.3.21 +- **Dropped `kotlinx-datetime`** — replaced by stdlib `kotlin.time.Clock` (Kotlin 2.3 stable) +- **Dropped `iosX64` target** — Compose Multiplatform 1.11.0 no longer ships `ios_x64` + variants (Intel Mac iOS simulators deprecated by Apple since Xcode 15+) + +### Tests +- 107 → **207** (+100). Coverage: `lod/` (24), `interaction/` (29), `model/ChartConfigTest` (8), + `render/AxisFormatterTest` (10), `RealtimeChartStateTest` (+9), `NumberFormatTest` (+6), + `TieredBufferTest` (+10), `CircularBufferTest` (+10), `AxisRendererTest` (3), + `LineSignalRendererTest` (2) + +### API surface +- `chart-realtime.api`: 161 → 428 LOC +- `chart-realtime.klib.api`: 211 → 501 LOC + +## [0.4.0] — 2026-05-21 — Portfolio Hardening + +15 tasks shipped via 3-reviewer audit (android-reviewer + mobile-performance-reviewer + +mobile-architect). Library moved from "prototype" to "senior-engineer-built KMP library". + +### Added +- LICENSE MIT +- `explicitApi() Strict` + Binary Compatibility Validator (BCV) 0.16.3 + klib enabled +- ABI baseline locked (`chart-realtime.api` 161 LOC, `chart-realtime.klib.api` 211 LOC) +- `hasData` boolean flag (replaces `POSITIVE_INFINITY` / `Float.MAX_VALUE` sentinels) +- `Snapshot.withMutableSnapshot` + `SnapshotApplyConflictException` retry helper +- M4 binning for Tier1/Tier2 (firstTs/V, minTs/V, maxTs/V, lastTs/V) with chronological dedup +- NaN/Inf/backward-timestamp guards on `push`; per-signal `lastPushedTs` tracking +- NumberFormat extracted from AxisRenderer (38 tests) +- 25 buffer/LoD tests (tier roll, window crossing, clear, LTTB branches) + +### Changed +- `CircularBuffer.writeIndex` `Int` → `Long` (fixes 124d overflow @ 200Hz) +- `kotlinx-datetime` 0.6.2 replaces `expect/actual currentTimeMs` (Platform.kt deleted) +- `dataVersion` → `mutableLongStateOf` (Compose-observable, idle = 0 recompositions) +- Single `buffer.snapshot()` per signal per frame via per-signal scratch arrays +- `TIER1_CAPACITY` 12k → 24k, `TIER2_CAPACITY` 5.4k → 10.8k (×2 → ×4 for M4 binning) +- Memory per signal: ~760 KB → ~1.14 MB + +### Removed +- `RenderLoop.kt`, `rememberFrameTick` (deleted) +- `maxBufferSeconds`, `xLabelDecimals`, `SignalConfig.label` (dead config) +- All `!!` and `@Suppress("UNUSED_EXPRESSION")` from commonMain + +### Internal lockdown +- `CircularBuffer`, `TieredBuffer`, `LodDecimator`, `dataVersion`, `rememberFrameTick`, + capacity constants + +### Tests +- 33 → 107 (+74) + +## [0.3.0] — 2026-05-21 — iOS Build Pipeline + Dirty Flag Render + +### Added +- iOS targets compile + link green: `iosX64`, `iosArm64`, `iosSimulatorArm64` +- `applyDefaultHierarchyTemplate()` auto-wires `iosMain` / `iosTest` +- XCFramework `ChartRealtime` (debug + release): `ios-arm64` + `ios-arm64_x86_64-simulator` +- Dirty-flag render: `dataVersion` counter, skip Canvas draw when no new data +- `targetFps = 30` default in `ChartConfig` (null = max display refresh rate) +- iOS-side `Platform.ios.kt`: `NSDate()` + `timeIntervalSinceReferenceDate` + 1970 epoch + offset (978_307_200s) +- KDoc on `RealtimeChart`, `RealtimeChartState`, `ChartConfig`, `LodMode`, `AxisLabelMode` +- README sections: LoD, AxisLabels, Material3 + +### Fixed +- Multiplatform replacements for JVM-only `String.format`: `formatFixed` / + `formatScientific` / `pow10` helpers in `AxisRenderer` +- `kotlin.concurrent.Volatile` + `kotlin.jvm.JvmName` imports (OptionalExpectation) +- X axis: show negative values instead of clamping to `"0s"` + +### Tests +- 17 → 33 (`RealtimeChartStateTest`: dataVersion + signal lifecycle) + +## [0.2.0] — 2026-05-21 — Polish + +### Added +- `TieredBuffer`: 3-tier ring buffer (5min full-rate, 10min @ 10Hz, 45min @ 1Hz), + ~5MB footprint for 1h of data +- `LodMode.LTTB` (Largest-Triangle-Three-Buckets, O(n) decimation) +- `AxisLabelMode` (`INSIDE` / `BESIDE` / `HIDDEN`) with `chartLeft` / `chartBottom` insets +- `T0`-relative X-axis timestamps with mm:ss / hh:mm:ss formatting (negative supported) +- 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 +- Sensor rate cap: 200Hz (5_000μs) + +### Changed +- Package rename: `eu.henesis` → `dev.dtrentin` +- `TieredBuffer` binning: `MEAN` → `MIN_MAX` (composable across tier boundaries, no + synthetic values, no C7 spike artifacts) +- `LodDecimator`: pre-allocated scratch arrays (zero GC at render time) + +### Removed +- `LodMode.MEAN` (introduced synthetic values absent in raw signal) + +## [0.1.0] — 2026-05-20 — MVP + +Initial implementation. Six phases shipped: + +1. Module scaffold (KMP + Compose Multiplatform + Android library) +2. `CircularBuffer` + `LodDecimator` +3. `RealtimeChartState` API +4. `RealtimeChart` composable + Canvas-based `SignalRenderer` + `AxisRenderer` +5. Demo app (`:app`) +6. Polish + initial docs + +[Unreleased]: https://github.com/davide-trentin/KMPCharts/compare/v0.5.0...HEAD +[0.5.0]: https://github.com/davide-trentin/KMPCharts/releases/tag/v0.5.0 +[0.4.0]: https://github.com/davide-trentin/KMPCharts/releases/tag/v0.4.0 +[0.3.0]: https://github.com/davide-trentin/KMPCharts/releases/tag/v0.3.0 +[0.2.0]: https://github.com/davide-trentin/KMPCharts/releases/tag/v0.2.0 +[0.1.0]: https://github.com/davide-trentin/KMPCharts/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d64b315 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,155 @@ +# Contributing to chart-realtime + +Thanks for considering a contribution. This document covers the build process, +project conventions, and the PR workflow. + +## Project layout + +``` +chart-realtime/ Kotlin Multiplatform library module +├── src/commonMain/ Shared code (Android + iOS) +├── src/commonTest/ Shared tests +├── src/androidMain/ Android-only code (currently empty) +├── src/iosMain/ iOS-only code (currently empty post-v0.5.0) +└── api/ ABI baseline (managed by BCV) + +app/ Android demo app (sandbox) +samples/ios/ SwiftUI iOS demo (planned for v1.0.0) +.paul/ Project planning state (PAUL workflow) +``` + +## Prerequisites + +- JDK **21** (Temurin recommended) +- Xcode (for iOS targets; macOS only) +- Android SDK with `compileSdk 35` +- No global Gradle install needed — wrapper provided + +## Building + +```bash +# Library — Android variant +./gradlew :chart-realtime:assembleRelease + +# Library — iOS variants +./gradlew :chart-realtime:compileKotlinIosArm64 +./gradlew :chart-realtime:compileKotlinIosSimulatorArm64 + +# XCFramework (consumes the library from Swift) +./gradlew :chart-realtime:assembleChartRealtimeXCFramework + +# Android demo app +./gradlew :app:installDebug + +# Publish library to local Maven (for consumer testing) +./gradlew :chart-realtime:publishToMavenLocal +``` + +## Testing + +```bash +# Shared common tests run on iOS simulator (canonical CI gate) +./gradlew :chart-realtime:iosSimulatorArm64Test + +# ABI compatibility check (must stay green; regen via apiDump only on intentional break) +./gradlew :chart-realtime:apiCheck +./gradlew :chart-realtime:apiDump +``` + +207 tests on the canonical run as of `v0.5.0`. Any PR must keep that count from +shrinking. + +## Code style + +- Kotlin official conventions; `explicitApi() Strict` enforced on `chart-realtime` +- 4-space indent, no tabs +- 120-column soft limit; wrap when readability suffers +- Avoid `!!` and `@Suppress` in `commonMain` (audit blockers since v0.4.0) +- `@Immutable` on data classes participating in `@Composable` parameter inference; + `@Stable` on mutable state holders observing through `MutableState` +- Use `kotlin.time.Clock` for current time; do not reintroduce `kotlinx-datetime` + +## Architecture conventions + +- **Buffer layer** (`buffer/`) — zero-alloc data structures. Pre-allocated scratch + arrays. No allocations on the render hot path +- **LoD layer** (`lod/`) — pluggable `LodStrategy` impls. Each constructor takes + `maxCount` + `maxPixelWidth` to size its scratch arrays once +- **Render layer** (`render/`) — `internal object` for the Canvas extension funs. + Cache `Stroke` / `TextStyle` instances; rely on Compose `TextMeasurer` LRU for + text layout +- **Interaction layer** (`interaction/`) — hoistable `@Stable` state holders. + Mutations route through Compose-observable backing state. Pointer-input lambdas + read from `LongArray` cache slots populated inside the draw lambda (1-frame stale + is acceptable for gesture handling) + +## Pull request workflow + +1. **Open an issue first** for non-trivial changes (new features, API additions, + performance work). Bug fixes can skip this +2. **Branch from `master`**. Name: `feat/`, `fix/`, `refactor/` +3. **Write tests**. Coverage requirements: + - Bug fix: regression test reproducing the bug, then the fix + - New public API: unit tests for happy path + edge cases + - Performance change: benchmark before/after numbers in PR body +4. **Run the full gate locally** before pushing: + ```bash + ./gradlew :chart-realtime:iosSimulatorArm64Test \ + :chart-realtime:assembleRelease \ + :chart-realtime:apiCheck \ + :app:assembleDebug + ``` +5. **ABI changes** — if `apiCheck` fails: + - If the change is intentional and you accept the break, regenerate the baseline + with `./gradlew :chart-realtime:apiDump`, commit the diff, and call it out in + the PR description + - If the change is unintentional, fix the code instead +6. **Commit format** — `: ` for the subject. Body lists + touched modules and intent. Example: + ``` + feat(interaction): add momentum scrolling to drag pan + + Modules: chart-realtime (interaction, RealtimeChart) + ``` +7. **No `--no-verify`** on commit hooks. Investigate and fix the underlying issue + if a hook fails +8. **Squash on merge** for small PRs; preserve commits for large feature work + +## Issue triage + +Bug reports must include: +- Affected version +- Platform (Android + API level / iOS + sim/device + iOS version) +- Reproducer (smallest snippet that triggers the issue) +- Expected vs actual behavior + +Feature requests must include: +- Use case (the underlying problem, not the proposed solution) +- Why the existing API doesn't already solve it +- Acceptance criteria (what "done" looks like) + +## Performance philosophy + +Library targets sustained 200Hz × multi-signal sensor data with bounded memory. +The hot path is the per-frame Compose recomposition + Canvas draw lambda. + +Three rules: +1. **No allocations on the render hot path.** All scratch arrays pre-allocated; + `Stroke` / `TextStyle` cached; `Pair` and `Map` iterators replaced with + `FloatArray` out-params and cached arrays +2. **Snapshot once per signal per frame.** `SignalEntry.scratchTs` / `scratchV` + pre-allocated; the Y-range scan and the path generation read the same snapshot +3. **Bounded memory regardless of session length.** `CircularBuffer.writeIndex` is + `Long`; tier ring buffers overwrite oldest first; 1h buffer = ~1.14 MB per signal + +Any change that breaks one of these requires explicit justification + benchmark +numbers + tests that pin the new behavior. + +## Reporting security issues + +Email `davide.trentin@henesis.eu` directly. Do not open a public issue. + +## License + +By contributing, you agree your contributions are licensed under the MIT License +of this project. diff --git a/app/src/main/kotlin/dev/dtrentin/chart/demo/ControlPanel.kt b/app/src/main/kotlin/dev/dtrentin/chart/demo/ControlPanel.kt new file mode 100644 index 0000000..f1ff622 --- /dev/null +++ b/app/src/main/kotlin/dev/dtrentin/chart/demo/ControlPanel.kt @@ -0,0 +1,303 @@ +package dev.dtrentin.chart.demo + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.dtrentin.chart.interaction.ViewportMode + +/** + * Material3 control surface. Stateless w.r.t. the [DemoConfig]; all mutations flow + * through [onConfigChange]. Status row reflects live runtime metrics ([statusFps], + * [statusTotalSamples], [statusMode]). + */ +@Composable +fun ControlPanel( + config: DemoConfig, + onConfigChange: (DemoConfig) -> Unit, + statusFps: Int, + statusTotalSamples: Long, + statusMode: ViewportMode, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceContainer, + shape = RoundedCornerShape(0.dp), + ) + .heightIn(max = 360.dp) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + SampleRateRow(config, onConfigChange) + SignalCountRow(config, onConfigChange) + WaveformGrid(config, onConfigChange) + WindowSecondsRow(config, onConfigChange) + LodRow(config, onConfigChange) + YLabelModeRow(config, onConfigChange) + YFormatterRow(config, onConfigChange) + SwitchRow(config, onConfigChange) + HorizontalDivider() + StatusRow( + sampleRateHz = config.sampleRateHz, + fps = statusFps, + signalCount = config.signalCount, + totalSamples = statusTotalSamples, + mode = statusMode, + ) + } +} + +// ── Sections ────────────────────────────────────────────────────────────────── + +@Composable +private fun SampleRateRow(config: DemoConfig, onConfigChange: (DemoConfig) -> Unit) { + LabeledRow( + label = "Sample rate", + value = "${config.sampleRateHz} Hz", + ) { + var raw by remember(config.sampleRateHz) { mutableStateOf(config.sampleRateHz.toFloat()) } + Slider( + value = raw, + onValueChange = { raw = it }, + onValueChangeFinished = { + onConfigChange(config.copy(sampleRateHz = snapSampleRate(raw))) + }, + valueRange = 10f..200f, + steps = 0, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun SignalCountRow(config: DemoConfig, onConfigChange: (DemoConfig) -> Unit) { + LabeledRow( + label = "Signals", + value = "${config.signalCount}", + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + for (n in 1..6) { + val selected = config.signalCount == n + FilterChip( + selected = selected, + onClick = { onConfigChange(config.copy(signalCount = n)) }, + label = { Text("$n") }, + ) + } + } + } +} + +@Composable +private fun WaveformGrid(config: DemoConfig, onConfigChange: (DemoConfig) -> Unit) { + Text("Waveforms", style = MaterialTheme.typography.labelMedium) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + for (i in 0 until config.signalCount) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + "S${i + 1}", + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(end = 4.dp), + ) + for (wf in WaveformType.entries) { + val selected = config.waveforms[i] == wf + FilterChip( + selected = selected, + onClick = { + val next = config.waveforms.toMutableList() + next[i] = wf + onConfigChange(config.copy(waveforms = next)) + }, + label = { Text(wf.label) }, + ) + } + } + } + } +} + +@Composable +private fun WindowSecondsRow(config: DemoConfig, onConfigChange: (DemoConfig) -> Unit) { + LabeledRow( + label = "Window", + value = "${"%.1f".format(config.windowSeconds)}s", + ) { + var raw by remember(config.windowSeconds) { mutableStateOf(config.windowSeconds) } + Slider( + value = raw, + onValueChange = { raw = it }, + onValueChangeFinished = { onConfigChange(config.copy(windowSeconds = raw)) }, + valueRange = 1f..60f, + steps = 0, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun LodRow(config: DemoConfig, onConfigChange: (DemoConfig) -> Unit) { + Text("LoD strategy", style = MaterialTheme.typography.labelMedium) + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + LodChoice.entries.forEachIndexed { i, choice -> + SegmentedButton( + selected = config.lodChoice == choice, + onClick = { onConfigChange(config.copy(lodChoice = choice)) }, + shape = SegmentedButtonDefaults.itemShape(i, LodChoice.entries.size), + ) { Text(choice.label) } + } + } +} + +@Composable +private fun YLabelModeRow(config: DemoConfig, onConfigChange: (DemoConfig) -> Unit) { + Text("Y axis labels", style = MaterialTheme.typography.labelMedium) + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + YLabelModeChoice.entries.forEachIndexed { i, choice -> + SegmentedButton( + selected = config.yLabelMode == choice, + onClick = { onConfigChange(config.copy(yLabelMode = choice)) }, + shape = SegmentedButtonDefaults.itemShape(i, YLabelModeChoice.entries.size), + ) { Text(choice.label) } + } + } +} + +@Composable +private fun YFormatterRow(config: DemoConfig, onConfigChange: (DemoConfig) -> Unit) { + Text("Y formatter", style = MaterialTheme.typography.labelMedium) + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + YFormatterChoice.entries.forEachIndexed { i, choice -> + SegmentedButton( + selected = config.yFormatter == choice, + onClick = { onConfigChange(config.copy(yFormatter = choice)) }, + shape = SegmentedButtonDefaults.itemShape(i, YFormatterChoice.entries.size), + ) { Text(choice.label) } + } + } +} + +@Composable +private fun SwitchRow(config: DemoConfig, onConfigChange: (DemoConfig) -> Unit) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Interaction", style = MaterialTheme.typography.labelMedium) + Switch( + checked = config.interactionEnabled, + onCheckedChange = { onConfigChange(config.copy(interactionEnabled = it)) }, + modifier = Modifier.padding(start = 6.dp), + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Dark theme", style = MaterialTheme.typography.labelMedium) + Switch( + checked = config.darkTheme, + onCheckedChange = { onConfigChange(config.copy(darkTheme = it)) }, + modifier = Modifier.padding(start = 6.dp), + ) + } + } +} + +@Composable +private fun StatusRow( + sampleRateHz: Int, + fps: Int, + signalCount: Int, + totalSamples: Long, + mode: ViewportMode, +) { + val modeLabel = when (mode) { + ViewportMode.Following -> "Following" + ViewportMode.Frozen -> "Frozen" + is ViewportMode.History -> "History" + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Library doesn't expose render-FPS publicly; we track producer push rate as a + // close proxy. Rate chip shows configured rate; "Push/s" chip shows measured rate. + StatusChip("Push/s", "$fps") + StatusChip("Rate", "${sampleRateHz}Hz") + StatusChip("Signals", "$signalCount") + StatusChip("Samples", formatSamples(totalSamples)) + StatusChip("Mode", modeLabel) + } +} + +@Composable +private fun StatusChip(key: String, value: String) { + AssistChip( + onClick = {}, + enabled = false, + label = { Text("$key: $value", style = MaterialTheme.typography.labelSmall) }, + colors = AssistChipDefaults.assistChipColors( + disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant, + disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) +} + +@Composable +private fun LabeledRow( + label: String, + value: String, + content: @Composable () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(label, style = MaterialTheme.typography.labelMedium) + Text(value, style = MaterialTheme.typography.labelSmall) + } + content() +} + +private fun formatSamples(n: Long): String = when { + n < 1_000 -> "$n" + n < 1_000_000 -> "${n / 1_000}k" + else -> "${"%.1f".format(n / 1_000_000.0)}M" +} diff --git a/app/src/main/kotlin/dev/dtrentin/chart/demo/DemoConfig.kt b/app/src/main/kotlin/dev/dtrentin/chart/demo/DemoConfig.kt new file mode 100644 index 0000000..31b8259 --- /dev/null +++ b/app/src/main/kotlin/dev/dtrentin/chart/demo/DemoConfig.kt @@ -0,0 +1,63 @@ +package dev.dtrentin.chart.demo + +/** + * Snapshot of all user-controlled demo settings. Acts as the single source of truth + * piped from the [ControlPanel] into [DemoScreen]. Immutable; updates produce a new + * instance via `copy(...)` from `mutableStateOf` callbacks. + */ +data class DemoConfig( + val sampleRateHz: Int, + val signalCount: Int, + val waveforms: List, + val windowSeconds: Float, + val lodChoice: LodChoice, + val yLabelMode: YLabelModeChoice, + val yFormatter: YFormatterChoice, + val interactionEnabled: Boolean, + val darkTheme: Boolean, +) { + companion object { + val SAMPLE_RATE_STEPS = listOf(10, 30, 50, 100, 150, 200) + val DEFAULT = DemoConfig( + sampleRateHz = 100, + signalCount = 4, + waveforms = listOf( + WaveformType.SINE, + WaveformType.SQUARE, + WaveformType.TRIANGLE, + WaveformType.NOISE, + WaveformType.SINE, + WaveformType.SQUARE, + ), + windowSeconds = 10f, + lodChoice = LodChoice.MinMaxLttb, + yLabelMode = YLabelModeChoice.BESIDE, + yFormatter = YFormatterChoice.Decimal, + interactionEnabled = true, + darkTheme = true, + ) + } +} + +enum class LodChoice(val label: String) { + MinMaxLttb("MinMaxLTTB"), + MinMax("MinMax"), + Lttb("LTTB"), +} + +enum class YLabelModeChoice(val label: String) { + INSIDE("Inside"), + BESIDE("Beside"), + HIDDEN("Hidden"), +} + +enum class YFormatterChoice(val label: String) { + Decimal("Decimal"), + UnitMv("mV"), + DateTime("DateTime"), + Time("Time"), +} + +/** Snap a slider value to the closest entry in [DemoConfig.SAMPLE_RATE_STEPS]. */ +fun snapSampleRate(raw: Float): Int = + DemoConfig.SAMPLE_RATE_STEPS.minBy { kotlin.math.abs(it - raw) } diff --git a/app/src/main/kotlin/dev/dtrentin/chart/demo/DemoScreen.kt b/app/src/main/kotlin/dev/dtrentin/chart/demo/DemoScreen.kt new file mode 100644 index 0000000..b9963bf --- /dev/null +++ b/app/src/main/kotlin/dev/dtrentin/chart/demo/DemoScreen.kt @@ -0,0 +1,248 @@ +package dev.dtrentin.chart.demo + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import dev.dtrentin.chart.RealtimeChart +import dev.dtrentin.chart.RealtimeChartState +import dev.dtrentin.chart.interaction.InteractionConfig +import dev.dtrentin.chart.interaction.ViewportMode +import dev.dtrentin.chart.interaction.rememberChartInteractionState +import dev.dtrentin.chart.lod.LodStrategy +import dev.dtrentin.chart.lod.LttbLodStrategy +import dev.dtrentin.chart.lod.MinMaxLodStrategy +import dev.dtrentin.chart.lod.MinMaxLttbLodStrategy +import dev.dtrentin.chart.model.AxisConfig +import dev.dtrentin.chart.model.AxisLabelMode +import dev.dtrentin.chart.model.ChartConfig +import dev.dtrentin.chart.model.ChartTheme +import dev.dtrentin.chart.model.DataConfig +import dev.dtrentin.chart.model.RenderConfig +import dev.dtrentin.chart.model.SignalConfig +import dev.dtrentin.chart.model.YRange +import dev.dtrentin.chart.model.rememberMaterialChartTheme +import dev.dtrentin.chart.render.AxisFormatter +import dev.dtrentin.chart.render.DateTimeAxisFormatter +import dev.dtrentin.chart.render.DecimalAxisFormatter +import dev.dtrentin.chart.render.TimeAxisFormatter +import dev.dtrentin.chart.render.UnitAxisFormatter +import kotlinx.coroutines.delay + +/** + * Top-level demo composable. Hosts the Material3 theme, the configuration panel, and + * the live chart. All settings flow through a single [DemoConfig] held in `remember`. + * + * Architecture: + * - `chartConfigKey` (a sub-data-class of [DemoConfig]) drives [ChartConfig] / state + * rebuilds. Changes to fields outside this key (e.g. sample rate, waveforms) do NOT + * rebuild state — they only re-launch the producer coroutine. + * - Signal registration is managed by a `LaunchedEffect(state, signalCount)` block + * that diffs the desired set against the current one, calling `addSignal` / + * `removeSignal` as needed. + * - FPS metric: derived from `dataVersion` deltas sampled every 1s. + */ +@Composable +fun DemoApp() { + var config by remember { mutableStateOf(DemoConfig.DEFAULT) } + val colorScheme = if (config.darkTheme) darkColorScheme() else lightColorScheme() + + MaterialTheme(colorScheme = colorScheme) { + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + DemoScreen( + config = config, + onConfigChange = { config = it }, + ) + } + } +} + +@Composable +private fun DemoScreen( + config: DemoConfig, + onConfigChange: (DemoConfig) -> Unit, +) { + // Chart-affecting subset of config. State rebuilds when any field here changes. + val chartTheme: ChartTheme = rememberMaterialChartTheme() + val chartConfigKey = ChartConfigKey( + windowSeconds = config.windowSeconds, + lodChoice = config.lodChoice, + yLabelMode = config.yLabelMode, + yFormatter = config.yFormatter, + themeKey = chartTheme, + ) + + // External counter — library doesn't expose dataVersion publicly. We track every + // push from the producer side, which equals total samples emitted. + val pushCounter = remember { PushCounter() } + + val state = remember(chartConfigKey) { + // Reset counter on state rebuild (old samples gone). + pushCounter.reset() + RealtimeChartState(config = buildChartConfig(chartConfigKey)) + } + + // Mirror of registered signal names. Library API doesn't expose a names accessor, + // so we keep our own. Cleared on state rebuild via `remember(state)`. + val registeredSignals = remember(state) { mutableSetOf() } + + // Reconcile signal registrations whenever signal count changes (or state rebuilds). + LaunchedEffect(state, config.signalCount) { + val desired = signalNames(config.signalCount).toSet() + // Remove stale. + val toRemove = registeredSignals - desired + for (name in toRemove) { + state.removeSignal(name) + registeredSignals.remove(name) + } + // Add new (in order, with color per index). + for ((i, name) in signalNames(config.signalCount).withIndex()) { + if (name !in registeredSignals) { + state.addSignal(name, SignalConfig(color = SIGNAL_COLORS[i], strokeWidth = 2f)) + registeredSignals.add(name) + } + } + } + + // Producer coroutine. Restarts on any input that changes the sample stream. + val waveformsKey = config.waveforms.take(config.signalCount) + LaunchedEffect(state, config.signalCount, config.sampleRateHz, waveformsKey) { + // Clear when waveforms or count change so old samples don't visually mix. + state.clear() + pushCounter.reset() + runSignalGenerator( + state = state, + signalNames = signalNames(config.signalCount), + waveforms = waveformsKey, + sampleRateHz = config.sampleRateHz, + onPush = { pushCounter.increment() }, + ) + } + + // FPS sampler: counts push deltas per second. + var statusFps by remember { mutableStateOf(0) } + var statusTotalSamples by remember { mutableLongStateOf(0L) } + LaunchedEffect(state) { + var lastCount = 0L + while (true) { + delay(1_000L) + val current = pushCounter.get() + statusFps = (current - lastCount).coerceAtLeast(0L).toInt() + lastCount = current + statusTotalSamples = current + } + } + + val interaction = if (config.interactionEnabled) { + rememberChartInteractionState(InteractionConfig()) + } else null + + val statusMode by remember(interaction) { + derivedStateOf { interaction?.mode ?: ViewportMode.Following } + } + + Column(modifier = Modifier.fillMaxSize()) { + ControlPanel( + config = config, + onConfigChange = onConfigChange, + statusFps = statusFps, + statusTotalSamples = statusTotalSamples, + statusMode = statusMode, + modifier = Modifier.fillMaxWidth(), + ) + HorizontalDivider() + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(8.dp), + ) { + RealtimeChart( + state = state, + modifier = Modifier.fillMaxSize(), + xWindowSeconds = config.windowSeconds, + theme = chartTheme, + interaction = interaction, + ) + } + } +} + +// ── Chart config construction ────────────────────────────────────────────────── + +/** Subset of [DemoConfig] that affects [ChartConfig]. Drives state rebuilds. */ +private data class ChartConfigKey( + val windowSeconds: Float, + val lodChoice: LodChoice, + val yLabelMode: YLabelModeChoice, + val yFormatter: YFormatterChoice, + val themeKey: ChartTheme, +) + +private fun buildChartConfig(key: ChartConfigKey): ChartConfig { + val lod: LodStrategy = when (key.lodChoice) { + LodChoice.MinMaxLttb -> MinMaxLttbLodStrategy() + LodChoice.MinMax -> MinMaxLodStrategy() + LodChoice.Lttb -> LttbLodStrategy() + } + val yLabel: AxisLabelMode = when (key.yLabelMode) { + YLabelModeChoice.INSIDE -> AxisLabelMode.INSIDE + YLabelModeChoice.BESIDE -> AxisLabelMode.BESIDE + YLabelModeChoice.HIDDEN -> AxisLabelMode.HIDDEN + } + val yFmt: AxisFormatter = when (key.yFormatter) { + YFormatterChoice.Decimal -> DecimalAxisFormatter(decimals = 2) + YFormatterChoice.UnitMv -> UnitAxisFormatter("mV", decimals = 2) + YFormatterChoice.DateTime -> DateTimeAxisFormatter(showSeconds = true) + YFormatterChoice.Time -> TimeAxisFormatter + } + return ChartConfig( + data = DataConfig( + xWindowSeconds = key.windowSeconds, + yRange = YRange.Auto(), + ), + axis = AxisConfig( + xLabelMode = AxisLabelMode.BESIDE, + yLabelMode = yLabel, + yLabelDecimals = 2, + showGrid = true, + xFormatter = TimeAxisFormatter, + yFormatter = yFmt, + ), + render = RenderConfig( + theme = key.themeKey, + lodStrategy = lod, + ), + ) +} + +// ── Signal naming + palette ──────────────────────────────────────────────────── + +private fun signalNames(count: Int): List = (0 until count).map { "S${it + 1}" } + +/** Distinct hues for up to 6 signals. Sourced from Material3 baseline + 2 custom hues. */ +val SIGNAL_COLORS: List = listOf( + Color(0xFF6750A4), // primary + Color(0xFF625B71), // secondary + Color(0xFF7D5260), // tertiary + Color(0xFFB3261E), // error + Color(0xFF006A6A), // teal custom + Color(0xFFE08F00), // amber custom +) diff --git a/app/src/main/kotlin/dev/dtrentin/chart/demo/MainActivity.kt b/app/src/main/kotlin/dev/dtrentin/chart/demo/MainActivity.kt index e3dae73..753ac9e 100644 --- a/app/src/main/kotlin/dev/dtrentin/chart/demo/MainActivity.kt +++ b/app/src/main/kotlin/dev/dtrentin/chart/demo/MainActivity.kt @@ -3,73 +3,13 @@ package dev.dtrentin.chart.demo import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import dev.dtrentin.chart.RealtimeChart -import dev.dtrentin.chart.RealtimeChartState -import dev.dtrentin.chart.model.ChartConfig -import dev.dtrentin.chart.model.DataConfig -import dev.dtrentin.chart.model.SignalConfig -import dev.dtrentin.chart.model.YRange -import kotlinx.coroutines.delay -import kotlin.math.PI -import kotlin.math.cos -import kotlin.math.sin -import kotlin.random.Random +/** + * Host activity for the chart-realtime demo. Composition entry point is [DemoApp]. + */ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContent { - MaterialTheme { - Surface(modifier = Modifier.fillMaxSize()) { - DemoScreen() - } - } - } + setContent { DemoApp() } } } - -@Composable -fun DemoScreen() { - val state = remember { - RealtimeChartState( - config = ChartConfig( - data = DataConfig( - xWindowSeconds = 10f, - yRange = YRange.Auto(), - ), - ), - ).apply { - addSignal("sin", SignalConfig(color = Color(0xFF6750A4))) - addSignal("cos", SignalConfig(color = Color(0xFF625B71))) - addSignal("noise", SignalConfig(color = Color(0xFF7D5260))) - addSignal("square", SignalConfig(color = Color(0xFFB3261E))) - addSignal("sawtooth", SignalConfig(color = Color(0xFF006A6A))) - } - } - - LaunchedEffect(state) { - val twoPi = (2.0 * PI).toFloat() - var t = 0f - val startMs = System.currentTimeMillis() - while (true) { - val ts = startMs + (t * 1000f).toLong() - state.push("sin", ts, sin(t * twoPi * 1.0f)) - state.push("cos", ts, cos(t * twoPi * 0.5f)) - state.push("noise", ts, Random.nextFloat() * 2f - 1f) - state.push("square", ts, if (sin(t * twoPi * 0.25f) > 0f) 1f else -1f) - state.push("sawtooth", ts, ((t * 0.5f) % 1f) * 2f - 1f) - t += 0.005f - delay(5) - } - } - - RealtimeChart(state = state, modifier = Modifier.fillMaxSize()) -} diff --git a/app/src/main/kotlin/dev/dtrentin/chart/demo/PushCounter.kt b/app/src/main/kotlin/dev/dtrentin/chart/demo/PushCounter.kt new file mode 100644 index 0000000..ed0f5c2 --- /dev/null +++ b/app/src/main/kotlin/dev/dtrentin/chart/demo/PushCounter.kt @@ -0,0 +1,15 @@ +package dev.dtrentin.chart.demo + +import java.util.concurrent.atomic.AtomicLong + +/** + * Thread-safe monotonic counter incremented on every `state.push(...)` from the + * synthetic generator. Used by the status panel to report push throughput and total + * sample count (library does not expose `dataVersion` as public API). + */ +class PushCounter { + private val n = AtomicLong(0L) + fun increment() { n.incrementAndGet() } + fun get(): Long = n.get() + fun reset() { n.set(0L) } +} diff --git a/app/src/main/kotlin/dev/dtrentin/chart/demo/SignalGenerator.kt b/app/src/main/kotlin/dev/dtrentin/chart/demo/SignalGenerator.kt new file mode 100644 index 0000000..44d60a6 --- /dev/null +++ b/app/src/main/kotlin/dev/dtrentin/chart/demo/SignalGenerator.kt @@ -0,0 +1,51 @@ +package dev.dtrentin.chart.demo + +import dev.dtrentin.chart.RealtimeChartState +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.CoroutineScope + +/** + * Synthetic multi-signal producer. Single coroutine pushes one sample per active signal + * per tick. Tick interval = `1000 / sampleRate` ms. + * + * Caller is responsible for re-launching this when [sampleRate], [signalNames], or + * [waveforms] change — typically by keying a `LaunchedEffect` on those values plus the + * [state] identity. + * + * @param state target chart state. Signals in [signalNames] must already be registered + * via [RealtimeChartState.addSignal] before this is invoked. + * @param signalNames ordered list of signal names. Index `i` produces waveform `waveforms[i]`. + * @param waveforms one [WaveformType] per signal. Length must equal [signalNames]. + * @param sampleRateHz target push rate (10..200 Hz typical). Effective rate is clamped to + * the resolution of `kotlinx.coroutines.delay` on the host. + */ +suspend fun CoroutineScope.runSignalGenerator( + state: RealtimeChartState, + signalNames: List, + waveforms: List, + sampleRateHz: Int, + onPush: () -> Unit = {}, +) { + require(signalNames.size == waveforms.size) { + "signalNames (${signalNames.size}) and waveforms (${waveforms.size}) must match" + } + if (signalNames.isEmpty() || sampleRateHz <= 0) return + + val tickIntervalMs = (1_000L / sampleRateHz).coerceAtLeast(1L) + val dt = 1f / sampleRateHz.toFloat() + val startMs = System.currentTimeMillis() + var t = 0f + + while (isActive) { + val ts = startMs + (t * 1000f).toLong() + for (i in signalNames.indices) { + // Phase-offset each signal so visually they don't overlap when same waveform. + val v = waveforms[i].sample(t, phaseOffset = i * 0.25f) + state.push(signalNames[i], ts, v) + onPush() + } + t += dt + delay(tickIntervalMs) + } +} diff --git a/app/src/main/kotlin/dev/dtrentin/chart/demo/WaveformType.kt b/app/src/main/kotlin/dev/dtrentin/chart/demo/WaveformType.kt new file mode 100644 index 0000000..7f18dee --- /dev/null +++ b/app/src/main/kotlin/dev/dtrentin/chart/demo/WaveformType.kt @@ -0,0 +1,35 @@ +package dev.dtrentin.chart.demo + +import kotlin.math.PI +import kotlin.math.sin +import kotlin.random.Random + +/** + * Synthetic waveform kinds for the demo signal generator. Each variant maps to a + * deterministic value at time `t` (seconds, monotonically increasing). + * + * Frequencies kept low (<1Hz) so the chart is visually readable at any sample rate. + */ +enum class WaveformType(val label: String) { + SINE("Sine"), + SQUARE("Square"), + TRIANGLE("Triangle"), + NOISE("Noise"); + + fun sample(t: Float, phaseOffset: Float = 0f): Float { + val tt = t + phaseOffset + return when (this) { + SINE -> sin((2f * PI.toFloat()) * 1.0f * tt) + SQUARE -> if (sin((2f * PI.toFloat()) * 0.5f * tt) >= 0f) 1f else -1f + TRIANGLE -> { + // Period = 1 / 0.3Hz ≈ 3.333s. Triangle ramps in [-1, +1]. + val period = 1f / 0.3f + val phase = ((tt % period) + period) % period + val norm = phase / period // 0..1 + if (norm < 0.5f) -1f + 4f * norm // -1 → +1 over first half + else 3f - 4f * norm // +1 → -1 over second half + } + NOISE -> Random.nextFloat() * 2f - 1f + } + } +} diff --git a/chart-realtime/README.md b/chart-realtime/README.md index 51dd24a..89a8371 100644 --- a/chart-realtime/README.md +++ b/chart-realtime/README.md @@ -1,110 +1,221 @@ # chart-realtime -KMP real-time line chart for Android. Renders multiple sensor signals at 200Hz+ without frame drops. +Real-time line chart for **Compose Multiplatform** (Android + iOS). Optimized for +sensor data: **200Hz+ multi-signal**, bounded memory, 1h+ rolling window, zero-alloc render path. + +> Status: `0.5.0-SNAPSHOT` — pre-`1.0.0`. API is stable on the public surface; Maven Central publishing lands in `1.0.0`. + +## Why it exists + +Existing KMP chart libraries (Vico, KoalaPlot, AAY-chart) target static datasets +and snapshot-style updates. None target sustained high-frequency streaming sensor +data with bounded memory. `chart-realtime` fills that gap. ## Features -- 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` +- **Multi-signal** rendering on a single Canvas — add / remove signals at runtime +- **Tiered ring buffer** (5min full-rate + 10min @ 10Hz + 45min @ 1Hz) — 1h of data per + signal at ~1.14 MB +- **Pluggable LoD**: `MinMaxLttbLodStrategy` (SOTA, default) / `MinMaxLodStrategy` / + `LttbLodStrategy`, or implement `LodStrategy` +- **Pluggable rendering**: implement `SignalRenderer` for scatter / area / bar (line + default ships) +- **Pluggable axis formatters**: `TimeAxisFormatter` / `DecimalAxisFormatter` / + `DateTimeAxisFormatter` / `UnitAxisFormatter("mV")`, or implement `AxisFormatter` +- **Interaction layer**: pinch zoom, drag pan, tap crosshair with per-signal values. + Swipe to right edge resumes auto-scroll +- **Data-driven recomposition** via Compose snapshots — idle = 0 recompositions +- **Material3 dynamic theming**, edge-to-edge / camera-cutout safe +- **Thread-safe push** from any background thread (`Snapshot.withMutableSnapshot` + + retry helper) +- **ABI lockdown** via Binary Compatibility Validator +- **Zero-alloc render** at steady state (cached Stroke / TextStyle / signalsArray / + per-signal scratch arrays) -## Installation +## Supported targets -```kotlin -// settings.gradle.kts -include(":chart-realtime") -``` +- Android (compileSdk 35, minSdk 26) +- iOS arm64 (real devices) + iosSimulatorArm64 (Apple Silicon simulators) +- Intel Mac simulators (`iosX64`) **not supported** since v0.5.0 (Compose + Multiplatform 1.11.0 dropped the variant; Apple deprecated Intel iOS sims since + Xcode 15+) + +## Install + +Pre-`1.0.0`: consume from Maven Local. -Or Maven local: ```bash ./gradlew :chart-realtime:publishToMavenLocal ``` ```kotlin +// settings.gradle.kts +dependencyResolutionManagement { + repositories { mavenLocal(); google(); mavenCentral() } +} + // build.gradle.kts implementation("dev.dtrentin:chart-realtime:0.5.0-SNAPSHOT") ``` -## Quick start (3 lines) +Post-`1.0.0`: Maven Central (planned). + +## Quick start ```kotlin -val state = remember { RealtimeChartState(ChartConfig()) } -LaunchedEffect(state) { - state.addSignal("ecg", SignalConfig(color = Color.Green)) - sensorFlow.collect { v -> state.push("ecg", System.currentTimeMillis(), v) } +@Composable +fun MyChart(sensorFlow: Flow) { + val state = remember { RealtimeChartState(ChartConfig()) } + val interaction = rememberChartInteractionState() + + LaunchedEffect(state) { + state.addSignal("ecg", SignalConfig(color = Color.Green)) + sensorFlow.collect { v -> + state.push("ecg", System.currentTimeMillis(), v) + } + } + + RealtimeChart( + state = state, + modifier = Modifier.fillMaxSize(), + interaction = interaction, + ) } -RealtimeChart(state = state, modifier = Modifier.fillMaxSize()) ``` ## Configuration +`ChartConfig` is split into three orthogonal sub-configs: + ```kotlin ChartConfig( - xWindowSeconds = 10f, // visible window width - yRange = YRange.Auto(), // or YRange.Fixed(-1f, 1f) - t0 = T0.FirstSample, // or T0.Fixed(epochMs) - targetFps = 30, // default 30 fps; null = max display refresh rate - theme = ChartTheme( // optional visual override - backgroundColor = Color(0xFF1A1A2E), - gridColor = Color(0x22FFFFFF), - axisColor = Color(0xFFBBBBBB), + data = DataConfig( + xWindowSeconds = 10f, + yRange = YRange.Auto(), + t0 = T0.FirstSample, + ), + axis = AxisConfig( + xLabelMode = AxisLabelMode.INSIDE, + yLabelMode = AxisLabelMode.BESIDE, + yLabelDecimals = 2, + showGrid = true, + xFormatter = TimeAxisFormatter, + yFormatter = UnitAxisFormatter("mV", decimals = 2), + ), + render = RenderConfig( + theme = ChartTheme(), + frameRate = FrameRate.Display, + lodStrategy = MinMaxLttbLodStrategy(), ), ) ``` -## Level of Detail +### Per-signal renderer override ```kotlin -ChartConfig(lodMode = LodMode.MIN_MAX) // default — zero-alloc, preserves peaks -ChartConfig(lodMode = LodMode.LTTB) // visually smooth, slight alloc per frame +state.addSignal("noise", SignalConfig( + color = Color.Red, + strokeWidth = 1f, + renderer = MyCustomRenderer, // implement SignalRenderer +)) ``` -## Axis Labels +## Interaction ```kotlin -ChartConfig( - xLabelMode = AxisLabelMode.INSIDE, // default — labels over chart area - yLabelMode = AxisLabelMode.BESIDE, // reserved margin for Y labels +val interaction = rememberChartInteractionState( + config = InteractionConfig( + zoomEnabled = true, + panEnabled = true, + tapCrosshairEnabled = true, + minXWindowSeconds = 0.5f, + maxXWindowSeconds = 3600f, + resumeFollowingThresholdMs = 500L, + ) ) -// AxisLabelMode.HIDDEN — hides labels on that axis +RealtimeChart(state, modifier, interaction = interaction) ``` -## Material3 Theme +Gestures: +- **Pinch** — zoom (clamps to `minXWindowSeconds` / `maxXWindowSeconds`) +- **Drag** — pan; mode switches to `History(anchorMs)` +- **Tap** — toggle crosshair; shows per-signal value at the tapped timestamp +- **Pan back to right edge** — auto resumes `Following` mode + +State exposed: `mode: ViewportMode { Following | Frozen | History(anchorMs) }`, +`crosshair: CrosshairState?`, `xWindowSecondsOverride: Float`, +`viewportOffsetMs: Long`. + +## Flow ingest API ```kotlin -val theme = rememberMaterialChartTheme() -RealtimeChart(state = state, theme = theme) +// Pre-timestamped samples +state.collectFrom("ecg", ecgFlow, scope) // Flow> + +// Auto-timestamp via kotlin.time.Clock +state.collectFrom("accel_x", accelFlow, scope) // Flow ``` -## Flow API +## Performance -```kotlin -// From Flow (auto-timestamp) -state.collectFrom("accel_x", accelerometerFlow, viewModelScope) +- 207 tests on `iosSimulatorArm64Test`, all green at every release +- `MinMaxLttbLodStrategy` measured at **1.80×** speedup vs pure LTTB on + Kotlin/Native scalar code (paper claims 10× under Rust + SIMD) +- Render hot path: 0 `Pair` / `Map` / `Iterator` allocations per frame at steady state +- Memory per signal: ~1.14 MB for 1h of data @ 200Hz +- Bisect-based `TieredBuffer.snapshotWindow` — 30× fewer per-frame copies on + 10s windows in 5min Tier0 buffers +- See [`docs/PERFORMANCE.md`](../docs/PERFORMANCE.md) for full numbers + +## Limitations (honest) + +- **Line rendering only** in default impl. Scatter / area / bar require custom + `SignalRenderer` impl +- **Single Y axis** per chart. No multi-Y, no log scale (planned post-`1.0.0`) +- **No annotations / threshold lines** yet (backlog) +- **No headless export** (PNG / CSV) — render is Compose-only +- **No SwiftUI demo app** yet (planned for `1.0.0`); iOS targets compile, link, + test, ship XCFramework +- **No Maven Central** yet — Maven Local only pre-`1.0.0` + +## Architecture -// From Flow> (explicit timestamp) -state.collectFrom("ecg", ecgFlow, viewModelScope) // ecgFlow emits (timestampMs, value) ``` +RealtimeChart (composable) + ├─ Canvas draw lambda + │ ├─ reads state.dataVersion (Compose-observable) + │ ├─ for each signal: + │ │ ├─ TieredBuffer.snapshot → SignalEntry.scratch (1× per frame) + │ │ ├─ LodStrategy.decimate → lodX / lodY (1× per frame) + │ │ └─ SignalRenderer.drawSignal (lodX, lodY, count, ...) + │ └─ AxisRenderer.drawXAxis / drawYAxis + ├─ pointer-input chain (when interaction != null) + │ ├─ detectTransformGestures → applyZoom + │ ├─ detectDragGestures → applyPan + │ └─ detectTapGestures → toggleCrosshair + └─ overlay: crosshair guide + per-signal dots + readout (when crosshair set) -## Performance notes - -- Data thread pushes at any rate; render thread reads at `targetFps` (default 30) or display refresh if null -- TieredBuffer keeps ~1h history per signal (3-tier ring, no caller config) -- LoD decimation reduces >pixel-count data to min/max per pixel column -- Pre-allocated arrays — zero GC on hot render path (except LoD scratch, see TODO in `LodDecimator.kt`) -- Dirty-flag skips canvas draw when buffer unchanged since last frame +RealtimeChartState + ├─ _signals: Map (Volatile, copy-on-write) + ├─ signalsArray: cached Array (invalidated on add/remove) + ├─ _dataVersion: mutableLongStateOf (Compose-observable counter) + └─ resolvedT0Ms: Long? (lazy from T0.FirstSample) +``` ## Requirements -- Android minSdk 26 -- Jetpack Compose / Compose Multiplatform 1.8+ -- Kotlin 2.1+ +- Android `minSdk 26`, `compileSdk 35` +- Kotlin `2.3.21+` +- Compose Multiplatform `1.11.0+` (or AndroidX Compose equivalent) +- JDK 21 toolchain for builds + +## Documentation + +- [`CHANGELOG.md`](../CHANGELOG.md) — all releases since `0.1.0` +- [`CONTRIBUTING.md`](../CONTRIBUTING.md) — build instructions + PR workflow + style guide +- [`docs/PERFORMANCE.md`](../docs/PERFORMANCE.md) — measured frame time / memory / allocation numbers +- [`.paul/ROADMAP.md`](../.paul/ROADMAP.md) — milestone history + backlog ## License -MIT License — see [LICENSE](../LICENSE). +MIT. See [`LICENSE`](../LICENSE). diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md new file mode 100644 index 0000000..33cc2d9 --- /dev/null +++ b/docs/PERFORMANCE.md @@ -0,0 +1,144 @@ +# Performance — chart-realtime + +Measured numbers for `0.5.0-SNAPSHOT` on `iosSimulatorArm64` and Android. +Benchmark module (vs Vico / KoalaPlot / MPAndroidChart) lands in `1.0.0`. + +## Test surface + +- **207** tests on `iosSimulatorArm64Test`, all green +- ABI baseline locked via Binary Compatibility Validator + - `chart-realtime.api`: 428 LOC + - `chart-realtime.klib.api`: 501 LOC + +## LoD algorithm performance + +Measured on `iosSimulatorArm64` (Apple Silicon iOS simulator). Input: +100_000 samples, output: 512 points. Median of 5 runs. + +| Algorithm | Time | vs pure LTTB | +|---|---:|---:| +| `LttbLodStrategy` (pure LTTB) | 5071 µs | 1.00× | +| `MinMaxLodStrategy` | 2400 µs (approx) | 2.1× | +| `MinMaxLttbLodStrategy` | **2814 µs** | **1.80×** | + +Paper claim ([arXiv 2305.00332](https://arxiv.org/abs/2305.00332)) is 10× under +Rust + SIMD. Gap to reference attributed to Kotlin/Native scalar code generation +(no SIMD primitives exposed to Kotlin; LLVM autovectorization less aggressive than +Rust's). Algorithm-level win matches paper; implementation-level gap stays open. + +### Visual fidelity (MinMaxLTTB vs MinMax baseline) + +Input: 100_000 samples of `sin(t / 100) + noise(0.1)`, output: 512 points. + +Min/max envelope drift: **< 5%** of pure-MinMax range. Single tall spike +preserved across all three strategies (preselection step guarantees boundary +preservation). + +## Buffer / snapshot performance + +`TieredBuffer.snapshotWindow` (T1, v0.5.0) uses bisect instead of linear scan. + +| Operation | Tier0 (5min @ 200Hz) | Tier1 (10min @ 10Hz) | Tier2 (45min @ 1Hz) | +|---|---:|---:|---:| +| `snapshot()` full | 60_000 copies | 24_000 copies | 10_800 copies | +| `snapshotWindow(10s)` linear | 60_000 scans, 2_000 writes | 24_000 scans, 100 writes | 10_800 scans, 10 writes | +| `snapshotWindow(10s)` bisect (T1) | 2_000 writes | 100 writes | 10 writes | + +**30× fewer per-frame copies** for the common case (10s window inside a 5min +Tier0 ring). + +`CircularBuffer.bisectStart` is O(log n), zero-alloc, overflow-safe midpoint +(`ushr 1`). + +## Render hot path — allocation budget + +Steady state, 4 signals, 200Hz push, 30fps render. Per-frame allocations: + +| Site | Pre-v0.4.0 | Post-v0.4.0 | Post-v0.5.0 | +|---|---:|---:|---:| +| `Pair` from `resolveYRange` | 1 | 1 | **0** (FloatArray out-param) | +| `Map.entries` iterator | 3 | 3 | **0** (cached `signalsArray`) | +| `Map.Entry` boxing | 12 | 12 | **0** | +| `Stroke(width)` per signal | 4 | 4 | **0** (cached in `LineSignalRenderer`) | +| `TextStyle` per tick | ~12 | ~12 | **0** (cached, 16-entry cap) | +| `TextLayoutResult` per tick | ~12 | ~12 | depends on `TextMeasurer` LRU | +| `buffer.snapshot` Long+Float arrays | 4×2 (8) | 4×1 (4) | 4×1 (4)¹ | + +¹ `buffer.snapshot` writes into pre-allocated `SignalEntry.scratchTs` / +`scratchV` (T11). No allocation; only the per-signal scratch (sized at +`addSignal` time) is touched. + +Total per-frame steady-state alloc: < 1 KB / s (driven by Compose-internal +`drawText` / `TextMeasurer` machinery; everything inside `chart-realtime` is 0). + +## Memory budget + +Per signal, full 1h capacity: + +``` +TIER0_CAPACITY = 5min × 200Hz × 1 sample = 60_000 records +TIER1_CAPACITY = 10min × 10Hz × 4 M4 records = 24_000 records +TIER2_CAPACITY = 45min × 1Hz × 4 M4 records = 10_800 records +TOTAL_CAPACITY = 94_800 records per signal +``` + +Per record: 8 bytes (`Long` ts) + 4 bytes (`Float` value) = 12 bytes. + +``` +Buffer memory: 94_800 × 12 B = 1_137_600 B ≈ 1.11 MB +Scratch memory: 94_800 × 12 B ≈ 1.11 MB (per-signal pre-allocated) +LoD scratch: ~2048 pixels × strategies (shared) ≈ 64 KB (one-shot) + +≈ 2.22 MB per signal at full 1h capacity +``` + +10 signals × 1h: **~22 MB**. Plain Compose+Kotlin overhead aside. + +## Compose stability report + +All public types verified `stable class` by the Compose compiler (T6): + +| Class | Verdict | +|---|---| +| `RealtimeChartState` | stable (`@Stable`) | +| `ChartConfig`, `DataConfig`, `AxisConfig`, `RenderConfig` | stable (`@Immutable`) | +| `SignalConfig`, `ChartTheme` | stable (`@Immutable`) | +| `T0`, `T0.FirstSample`, `T0.Fixed` | stable (`@Immutable` sealed) | +| `YRange`, `YRange.Auto`, `YRange.Fixed` | stable (`@Immutable` sealed) | +| `FrameRate`, `FrameRate.Display`, `FrameRate.Fixed` | stable (`@Immutable` sealed) | +| `LodMode`, `AxisLabelMode` | stable (enum, Compose-inferred) | +| `SignalRenderer` (interface) | stable (`@Stable`) | +| `LineSignalRenderer` (object) | stable (singleton) | +| `AxisFormatter` (interface + 4 impls) | stable (`@Immutable`) | +| `ChartInteractionState` | stable (`@Stable`) | +| `ViewportMode`, `CrosshairState`, `InteractionConfig` | stable (`@Immutable`) | + +Recomposition driven by `dataVersion: mutableLongStateOf` only. Idle = 0 +recompositions verified by snapshot lambda equality check. + +## Threading + +- **Producer** (any thread) — `push(name, ts, value)` routes through + `Snapshot.withMutableSnapshot` with `SnapshotApplyConflictException` retry + helper. Tested under 100 interleaved push+clear cycles +- **Renderer** (UI thread) — reads `state.signalsArray` + per-signal + `entry.scratch*`. Visibility via `@Volatile` on `_signals` / `resolvedT0Ms` / + `SignalEntry.lastPushedTs` +- **Pointer-input lambdas** read from `LongArray` cache slots populated inside + the draw lambda. 1-frame stale tolerance is acceptable for gesture handling + +## What is not measured yet + +The following land in `1.0.0` with a dedicated benchmark module: + +- Sustained frame time @ 200Hz × N signals on real hardware (Pixel 6, iPhone 13) +- Battery impact over 1h sessions +- Comparison vs Vico / KoalaPlot / MPAndroidChart (push throughput, frame time, + memory steady-state) +- Android macrobench (Jetpack Macrobenchmark) for cold/warm startup + render + frame-time distribution +- iOS Instruments allocation tracker capture + +If the library loses any specific comparison, the result is documented honestly. +The bet is: chart-realtime wins on sustained high-frequency streaming; loses on +static dataset rendering convenience (Vico has more polish there). diff --git a/gradle.properties b/gradle.properties index 5c868a2..f834205 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,12 +7,13 @@ kotlin.incremental=true android.useAndroidX=true android.nonTransitiveRClass=true -# AGP 9.0 compat: bypass new DSL + built-in Kotlin so KMP `com.android.library` -# keeps working alongside `org.jetbrains.kotlin.multiplatform`. Migration to -# `com.android.kotlin.multiplatform.library` is deferred (out of D1 scope). +# AGP 9.0 + KMP compat: bypass new DSL + built-in Kotlin so KMP modules using +# `com.android.library` keep working alongside `org.jetbrains.kotlin.multiplatform`. +# Migration to `com.android.kotlin.multiplatform.library` (9.2.0) was probed and +# rejected: new plugin removes per-variant `assembleRelease` tasks, alters Maven +# publication graph, and forces XCFramework default hierarchy to include +# macos/tvos/watchos — requires ABI baseline regen and breaks publish surface. +# These two properties emit deprecation warnings (removed in AGP 10.0). Re-evaluate +# when bumping past Compose-MP 1.11.0 / AGP 9.2.0. android.builtInKotlin=false android.newDsl=false -# Kotlin 2.3 defaults consumer to non-packed KLIBs; Compose-MP 1.11.0 still -# publishes packed klibs for native targets. Disable non-packed consumption -# so apiCheck and native compile resolve Compose-MP variants correctly. -kotlin.internal.klibs.non-packed=false diff --git a/samples/ios/ChartRealtimeDemo/.gitignore b/samples/ios/ChartRealtimeDemo/.gitignore new file mode 100644 index 0000000..14bb415 --- /dev/null +++ b/samples/ios/ChartRealtimeDemo/.gitignore @@ -0,0 +1,5 @@ +.build/ +.swiftpm/ +Package.resolved +*.xcuserdata/ +DerivedData/ diff --git a/samples/ios/ChartRealtimeDemo/Package.swift b/samples/ios/ChartRealtimeDemo/Package.swift new file mode 100644 index 0000000..54da70a --- /dev/null +++ b/samples/ios/ChartRealtimeDemo/Package.swift @@ -0,0 +1,26 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "ChartRealtimeDemo", + platforms: [ + .iOS(.v16) + ], + products: [ + .library( + name: "ChartRealtimeDemo", + targets: ["ChartRealtimeDemo"] + ), + ], + targets: [ + .binaryTarget( + name: "ChartRealtime", + path: "../../../chart-realtime/build/XCFrameworks/debug/ChartRealtime.xcframework" + ), + .target( + name: "ChartRealtimeDemo", + dependencies: ["ChartRealtime"], + path: "Sources/ChartRealtimeDemo" + ), + ] +) diff --git a/samples/ios/ChartRealtimeDemo/README.md b/samples/ios/ChartRealtimeDemo/README.md new file mode 100644 index 0000000..bfcce67 --- /dev/null +++ b/samples/ios/ChartRealtimeDemo/README.md @@ -0,0 +1,118 @@ +# ChartRealtime iOS Demo + +SwiftUI app consuming `ChartRealtime.xcframework` via Swift Package Manager. + +## Build + +Prerequisites: +- Xcode 15+ +- macOS on Apple Silicon (Intel Mac simulators are NOT supported since v0.5.0) +- Local `ChartRealtime.xcframework` built via Gradle + +From the repo root: + +```bash +./gradlew :chart-realtime:assembleChartRealtimeDebugXCFramework +``` + +This produces: + +``` +chart-realtime/build/XCFrameworks/debug/ChartRealtime.xcframework +``` + +The `Package.swift` references it via a local `binaryTarget`: + +```swift +.binaryTarget( + name: "ChartRealtime", + path: "../../../chart-realtime/build/XCFrameworks/debug/ChartRealtime.xcframework" +) +``` + +Then resolve + build the SPM package: + +```bash +cd samples/ios/ChartRealtimeDemo +swift package resolve +swift build +``` + +To run the app, open the package in Xcode (`File > Open... > Package.swift`) +or wrap it in a thin app target. A standalone `xcodeproj` is intentionally NOT +shipped in this scaffold. + +## Current limitation + +`chart-realtime` exposes the `@Composable fun RealtimeChart` composable, but it +does NOT yet export a `ComposeUIViewController` factory wrapping it into a +plain `UIViewController` suitable for SwiftUI hosting. + +Until that bridge ships (planned for `v1.0.0` T1), this demo: +- Verifies the XCFramework is consumable from Swift via SPM `binaryTarget` +- Instantiates the full Kotlin-side public API (`RealtimeChartState`, + `ChartConfig`, `DataConfig`, `AxisConfig`, `RenderConfig`, + `MinMaxLttbLodStrategy`, `TimeAxisFormatter`, `DecimalAxisFormatter`, + `YRange.Auto`, `T0.FirstSample`, `FrameRate.Display`, + `LineSignalRenderer`, `SignalConfig`, `ChartTheme`) from SwiftUI +- Registers a signal via `state.addSignal(name:signalConfig:)` +- Does NOT render the actual chart + +## Enabling the full demo (post-v1.0.0 T1) + +In `chart-realtime/src/iosMain/`: + +```kotlin +import androidx.compose.ui.window.ComposeUIViewController +import platform.UIKit.UIViewController + +public fun RealtimeChartViewController( + state: RealtimeChartState, + xWindowSeconds: Float = state.config.data.xWindowSeconds, +): UIViewController = ComposeUIViewController { + RealtimeChart(state = state, xWindowSeconds = xWindowSeconds) +} +``` + +Then in SwiftUI: + +```swift +import SwiftUI +import ChartRealtime +import UIKit + +struct ChartHost: UIViewControllerRepresentable { + let state: RealtimeChartState + + func makeUIViewController(context: Context) -> UIViewController { + // Kotlin top-level functions are exposed under the Kt-suffixed class + // in the auto-generated ObjC header; exact symbol depends on file name. + return RealtimeChartViewControllerKt.RealtimeChartViewController( + state: state, + xWindowSeconds: 10.0 + ) + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} +} +``` + +## ObjC bridge gotchas observed while authoring this demo + +- All Kotlin data-class constructors lose default arguments when crossing the + ObjC bridge — callers must pass every constructor argument explicitly. +- Sealed-class objects (`YRange.Auto`, `T0.FirstSample`, `FrameRate.Display`) + are exposed as classes with no-arg `init()`. Use `YRange.Auto()`, etc. +- Compose `Color` is bridged as `uint64_t` (Kotlin `ULong`) — pass raw ARGB + packed values: `0xFF00FF88` for opaque mint green. +- `LineSignalRenderer` is a Kotlin `object` (singleton) — both `LineSignalRenderer()` + and `LineSignalRenderer.shared` work in Swift. +- `dataVersion` is currently not exposed as a Swift-visible property of + `RealtimeChartState` (it is a Compose `MutableState` not flattened by + Kotlin/Native ObjC export). A `var dataVersionInt: Int` getter would help. + +## Files + +- `Package.swift` — SPM manifest, links `ChartRealtime.xcframework` via local `binaryTarget` +- `Sources/ChartRealtimeDemo/DemoApp.swift` — SwiftUI `@main` entry point +- `Sources/ChartRealtimeDemo/DemoView.swift` — placeholder view + Kotlin smoke test diff --git a/samples/ios/ChartRealtimeDemo/Sources/ChartRealtimeDemo/DemoApp.swift b/samples/ios/ChartRealtimeDemo/Sources/ChartRealtimeDemo/DemoApp.swift new file mode 100644 index 0000000..a2051d1 --- /dev/null +++ b/samples/ios/ChartRealtimeDemo/Sources/ChartRealtimeDemo/DemoApp.swift @@ -0,0 +1,11 @@ +import SwiftUI +import ChartRealtime + +@main +struct DemoApp: App { + var body: some Scene { + WindowGroup { + DemoView() + } + } +} diff --git a/samples/ios/ChartRealtimeDemo/Sources/ChartRealtimeDemo/DemoView.swift b/samples/ios/ChartRealtimeDemo/Sources/ChartRealtimeDemo/DemoView.swift new file mode 100644 index 0000000..f5d0f1e --- /dev/null +++ b/samples/ios/ChartRealtimeDemo/Sources/ChartRealtimeDemo/DemoView.swift @@ -0,0 +1,115 @@ +import SwiftUI +import ChartRealtime + +struct DemoView: View { + @State private var smokeResult: String = "Running smoke test..." + + var body: some View { + VStack(spacing: 16) { + Text("ChartRealtime — iOS Demo") + .font(.title) + .padding(.top) + + Text("XCFramework loaded. Bridge code required to host the Compose chart in SwiftUI — see README.") + .font(.callout) + .multilineTextAlignment(.center) + .padding(.horizontal) + + ScrollView { + Text(smokeResult) + .font(.system(.footnote, design: .monospaced)) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(Color.gray.opacity(0.1)) + .cornerRadius(8) + .padding(.horizontal) + + Spacer() + } + .onAppear { + smokeResult = runKotlinSmokeTest() + } + } + + /// Touches Kotlin-side public API surface via the auto-generated ObjC bridge + /// to verify the XCFramework is consumable from Swift. Does NOT render a chart. + private func runKotlinSmokeTest() -> String { + var log: [String] = [] + + // 1. ChartTheme — all five params required (no defaults across ObjC bridge). + // Color args are uint64 ARGB packed (ULong). Use 0xAARRGGBB. + let theme = ChartTheme( + backgroundColor: 0xFF000000, + gridColor: 0xFF333333, + axisColor: 0xFFCCCCCC, + labelColor: 0xFFFFFFFF, + strokeWidth: 1.5 + ) + log.append("OK ChartTheme(...)") + + // 2. LOD strategy — concrete impl with required params. + let lod = MinMaxLttbLodStrategy( + ratio: 4, + maxCount: 100_000, + maxPixelWidth: 2048 + ) + log.append("OK MinMaxLttbLodStrategy(ratio:4, maxCount:100000, maxPixelWidth:2048)") + + // 3. RenderConfig — needs theme, frameRate, lodStrategy. + let render = RenderConfig( + theme: theme, + frameRate: FrameRate.Display(), + lodStrategy: lod + ) + log.append("OK RenderConfig(theme:, frameRate: FrameRate.Display(), lodStrategy:)") + + // 4. AxisConfig — formatter protocol implementations. + let axis = AxisConfig( + xLabelMode: AxisLabelMode.inside, + yLabelMode: AxisLabelMode.inside, + yLabelDecimals: 2, + showGrid: true, + xFormatter: TimeAxisFormatter(), + yFormatter: DecimalAxisFormatter(decimals: 2) + ) + log.append("OK AxisConfig(...)") + + // 5. DataConfig — Y range strategy + T0 origin. + let data = DataConfig( + xWindowSeconds: 10.0, + yRange: YRange.Auto(paddingFraction: 0.1), + t0: T0.FirstSample() + ) + log.append("OK DataConfig(xWindowSeconds:10, yRange: YRange.Auto, t0: T0.FirstSample)") + + // 6. ChartConfig — composite. + let config = ChartConfig(data: data, axis: axis, render: render) + log.append("OK ChartConfig(data:, axis:, render:)") + + // 7. RealtimeChartState — the entrypoint for push API. + let state = RealtimeChartState(config: config) + log.append("OK RealtimeChartState(config:)") + + // 8. Register a signal — exercises the (name:signalConfig:) bridge. + state.addSignal( + name: "v1", + signalConfig: SignalConfig( + color: 0xFF00FF88, + strokeWidth: 2.0, + visible: true, + renderer: LineSignalRenderer() + ) + ) + log.append("OK state.addSignal(name:\"v1\", signalConfig:)") + + log.append("") + log.append("All Kotlin types accessible from Swift.") + log.append("state.config.data.xWindowSeconds=\(state.config.data.xWindowSeconds)") + return log.joined(separator: "\n") + } +} + +#Preview { + DemoView() +}