commit b9ee5ddfa3fcf617edc8256e3941a6c7ab196881 Author: dtrentin Date: Thu May 21 01:48:51 2026 +0200 Initial commit: chart-realtime KMP library v0.4.1-SNAPSHOT - RealtimeChart composable with Canvas-based rendering - TieredBuffer: 3-tier ring buffer (5min full-rate, 10min @10Hz, 45min @1Hz) for 1h data support with ~5MB footprint - LodDecimator: MEAN / MIN_MAX / MIN_MAX(default) / LTTB render-time decimation - AxisRenderer: X/Y axis with INSIDE/BESIDE/HIDDEN label modes, T0-relative timestamps, scientific notation - SignalRenderer: clipRect-bounded path rendering with LoD - ChartTheme: Material3-compatible dynamic theming (light/dark) - package: dev.dtrentin.chart Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1f39969 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Gradle +.gradle/ +build/ +**/build/ +local.properties + +# IntelliJ / Android Studio +.idea/ +*.iml +*.iws +*.ipr + +# Kotlin +.kotlin/ + +# OS +.DS_Store +Thumbs.db diff --git a/.paul/PROJECT.md b/.paul/PROJECT.md new file mode 100644 index 0000000..d5b5d6f --- /dev/null +++ b/.paul/PROJECT.md @@ -0,0 +1,106 @@ +# chart-realtime + +## What This Is + +Standalone KMP module (Android-first) providing a high-performance Compose-based real-time line chart for sensor data visualization. Renders N simultaneous signals at 200Hz+ using Canvas with FIFO circular buffers and Level-of-Detail decimation. Packaged as a dependency consumable by any Android/KMP app. + +## Core Value + +Android developers can plot multiple high-frequency sensor signals (200Hz+) on a single chart without dropping frames or overloading the device. + +## Current State + +| Attribute | Value | +|-----------|-------| +| Type | Application (library module) | +| Version | 0.0.0 | +| Status | Initializing | +| Last Updated | 2026-05-20 | + +## Requirements + +### Core Features + +- **Multi-signal real-time rendering** — N signals on same chart, each with own color/style, added/removed dynamically +- **FIFO circular buffer** — pre-allocated, lock-free write (data thread) / safe read (render thread), maxBufferSeconds configurable by caller +- **Level-of-Detail decimation** — min/max envelope per pixel column when buffer > pixel width; O(pixels) render cost regardless of buffer size +- **Flexible axis control** — X axis: seconds from t0 (T0.FirstSample or T0.Fixed); Y axis: YRange.Auto(padding) or YRange.Fixed(min, max) +- **Render rate decoupling** — data ingestion at any rate; rendering via withFrameNanos at display refresh rate (throttleable via targetFps param) +- **Flow-based API** — addSignal(name, flow, config) + manual push(name, timestampMs, value) + +### Validated (Shipped) +None yet. + +### Active (In Progress) +None yet. + +### Planned (Next) +- Phase 1: KMP module scaffold + Gradle config +- Phase 2: CircularBuffer + LodDecimator + unit tests +- Phase 3: RealtimeChartState + Flow integration +- Phase 4: Canvas rendering engine (RenderLoop, AxisRenderer, SignalRenderer) +- Phase 5: Demo Android app (5+ signals at 200Hz) +- Phase 6: Material3 polish + API documentation + +### Out of Scope + +- iOS support (deferred to future milestone) +- Touch interaction / zoom / pan +- SciChart/paid-lib integration +- Network data sources (consumer provides Flow) + +## Target Users + +**Primary:** Android/KMP developers building sensor dashboards (IoT, medical, industrial) +- Need 200Hz+ real-time plotting +- Want drop-in Compose composable +- Expect Material3 visual consistency + +## Constraints + +### Technical Constraints + +- KMP commonMain for all rendering logic (Compose Multiplatform Canvas) +- No object allocation in draw loop (pre-allocated FloatArray paths) +- Single draw call per signal (drawLines with FloatArray) +- Lock-free circular buffer (data thread != render thread) +- maxBufferSeconds: caller responsibility, no hard cap enforced by lib + +### Business Constraints + +- Standalone publishable module (Maven local / GitHub Packages) +- No paid/commercial dependencies + +## Key Decisions + +| Decision | Rationale | Date | Status | +|----------|-----------|------|--------| +| Canvas custom rendering over existing libs | Vico/MPAndroidChart cannot handle 200Hz+ without axis jumps or FPS drops | 2026-05-20 | Active | +| Render/data rate decoupling via withFrameNanos | 200Hz data != 60fps display; buffer absorbs delta | 2026-05-20 | Active | +| LoD min/max decimation | 1h buffer at 200Hz = 720k pts; screen = ~800px; must reduce O(n)→O(px) | 2026-05-20 | Active | +| maxBufferSeconds configurable, user responsibility | Avoid arbitrary caps; user knows their memory budget | 2026-05-20 | Active | +| Android-first, iOS deferred | Reduce scope, ship faster | 2026-05-20 | Active | + +## Success Metrics + +| Metric | Target | Current | Status | +|--------|--------|---------|--------| +| Sustained FPS at 200Hz, 5 signals | ≥55fps | - | Not started | +| Memory per signal per hour at 200Hz | ≤3MB | - | Not started | +| Demo app compiles and runs | Yes | - | Not started | +| API: add signal in ≤3 lines of code | Yes | - | Not started | + +## Tech Stack + +| Layer | Technology | Notes | +|-------|------------|-------| +| Language | Kotlin (KMP) | commonMain + androidMain | +| UI | Jetpack Compose / Compose Multiplatform | Canvas DrawScope | +| Build | Gradle + Version Catalog | Convention plugin if needed | +| DI | None (library, no DI framework) | Consumer wires state | +| Testing | kotlin.test + JUnit5 (androidTest) | Buffer + LoD unit tests | +| Demo | Android app module | 5+ signals, 200Hz simulation | + +--- +*PROJECT.md — Updated when requirements or context change* +*Last updated: 2026-05-20* diff --git a/.paul/ROADMAP.md b/.paul/ROADMAP.md new file mode 100644 index 0000000..674d0ac --- /dev/null +++ b/.paul/ROADMAP.md @@ -0,0 +1,124 @@ +# Roadmap: chart-realtime + +## 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. + +## Current Milestone + +**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 + +--- +*Roadmap created: 2026-05-20* diff --git a/.paul/STATE.md b/.paul/STATE.md new file mode 100644 index 0000000..9679ede --- /dev/null +++ b/.paul/STATE.md @@ -0,0 +1,61 @@ +# Project State + +## Project Reference + +See: .paul/PROJECT.md (updated 2026-05-20) + +**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 Position + +Milestone: v0.2.0 +Phase: 3 of 4 — In Progress +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. + +Progress: +- Milestone: [██████████] 100% +- Phase 6: [██████████] 100% + +## Loop Position + +``` +PLAN ──▶ APPLY ──▶ UNIFY + ✓ ✓ ✓ [v0.1.0 SHIPPED] +``` + +## Accumulated Context + +### Decisions + +| Decision | Phase | Impact | +|----------|-------|--------| +| 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 | + +### 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 | + +### 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/ + +--- +*STATE.md — Updated after every significant action* diff --git a/.paul/paul.json b/.paul/paul.json new file mode 100644 index 0000000..9aba4dd --- /dev/null +++ b/.paul/paul.json @@ -0,0 +1,25 @@ +{ + "name": "chart-realtime", + "version": "0.0.0", + "milestone": { + "name": "v0.1.0 MVP", + "version": "0.1.0", + "status": "not_started" + }, + "phase": { + "number": 0, + "name": "None", + "status": "not_started" + }, + "loop": { + "plan": null, + "position": "IDLE" + }, + "timestamps": { + "created_at": "2026-05-20T00:00:00Z", + "updated_at": "2026-05-20T00:00:00Z" + }, + "satellite": { + "groom": true + } +} diff --git a/.paul/phases/v0.2.0-plan.md b/.paul/phases/v0.2.0-plan.md new file mode 100644 index 0000000..37fa17d --- /dev/null +++ b/.paul/phases/v0.2.0-plan.md @@ -0,0 +1,53 @@ +# v0.2.0 — Bug Fix + Polish + +## Milestone Goal +Fix axis visibility, add Material3 theme support, dynamic X-window, and scratch-array optimization. + +## Bugs (from user, 2026-05-20) +1. Axis not visible — AxisRenderer draws only lines, no text labels +2. X-axis length not dynamically adjustable from UI +3. Chart colors must follow Material3 theme (primary, onPrimary, surface, etc.) +4. Signal colors user-decided (already in SignalConfig.color — document + default fix) + +## Deferred from v0.1.0 +- LodDecimator scratch array allocation per frame +- Double snapshot per frame in RealtimeChart + +## Phases + +| Phase | Name | Status | +|-------|------|--------| +| 1 | Material3 ChartTheme factory | Not started | +| 2 | Axis text labels (TextMeasurer) | Not started | +| 3 | Dynamic xWindowSeconds param | Not started | +| 4 | LodDecimator scratch optimization | Not started | + +## Phase Details + +### Phase 1: Material3 ChartTheme factory +File: `chart-realtime/src/commonMain/kotlin/eu/henesis/chart/model/ChartTheme.kt` +Add `@Composable fun rememberMaterialChartTheme(): ChartTheme` that reads from `MaterialTheme.colorScheme`: +- `backgroundColor = colorScheme.surface` +- `gridColor = colorScheme.outlineVariant.copy(alpha = 0.4f)` +- `axisColor = colorScheme.outline` +- `labelColor = colorScheme.onSurface` +- Add `labelColor: Color` field to ChartTheme (for axis text) +Signal colors remain in SignalConfig.color — user-decided. + +### Phase 2: Axis text labels +File: `chart-realtime/src/commonMain/kotlin/eu/henesis/chart/render/AxisRenderer.kt` +- Add TextMeasurer param to drawXAxis / drawYAxis +- Draw tick values as text at each grid line +- Add padding (left ~48dp for Y labels, bottom ~24dp for X labels) — canvas inset via Modifier or internal inset + +ChartTheme needs `labelColor: Color` (Phase 1 prereq). + +### Phase 3: Dynamic xWindowSeconds +File: `chart-realtime/src/commonMain/kotlin/eu/henesis/chart/RealtimeChart.kt` +Add `xWindowSeconds: Float = state.config.xWindowSeconds` parameter. +Caller can pass `by remember { mutableStateOf(10f) }` and wire a Slider. + +### Phase 4: LodDecimator scratch optimization +File: `chart-realtime/src/commonMain/kotlin/eu/henesis/chart/buffer/LodDecimator.kt` +Fix TODO: pass pre-allocated `colMin`, `colMax`, `colHit` arrays as params. +RealtimeChart pre-allocates and passes them. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..264976a --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,40 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.compose.compiler) +} + +android { + namespace = "dev.dtrentin.chart.demo" + compileSdk = 35 + + defaultConfig { + applicationId = "dev.dtrentin.chart.demo" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "0.1.0" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { jvmTarget = "17" } + + buildFeatures { compose = true } + + sourceSets["main"].kotlin.srcDirs("src/main/kotlin") +} + +dependencies { + implementation(project(":chart-realtime")) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.activity.compose) + implementation(libs.coroutines.core) +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..765c4cb --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + diff --git a/app/src/main/kotlin/dev/dtrentin/chart/demo/MainActivity.kt b/app/src/main/kotlin/dev/dtrentin/chart/demo/MainActivity.kt new file mode 100644 index 0000000..b406b2c --- /dev/null +++ b/app/src/main/kotlin/dev/dtrentin/chart/demo/MainActivity.kt @@ -0,0 +1,73 @@ +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.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 + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + DemoScreen() + } + } + } + } +} + +@Composable +fun DemoScreen() { + val state = remember { + RealtimeChartState( + config = ChartConfig( + xWindowSeconds = 10f, + yRange = YRange.Auto(), + maxBufferSeconds = 3600f, + ), + ).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/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..8ff3517 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.compose.multiplatform) apply false + alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.android.application) apply false +} diff --git a/chart-realtime/README.md b/chart-realtime/README.md new file mode 100644 index 0000000..d04b235 --- /dev/null +++ b/chart-realtime/README.md @@ -0,0 +1,80 @@ +# chart-realtime + +KMP real-time line chart for Android. Renders multiple sensor signals at 200Hz+ without frame drops. + +## Features + +- Multiple signals on one chart, added/removed dynamically +- FIFO circular buffer — lock-free writes at 200Hz+, safe render reads at 60fps +- Level-of-Detail decimation — renders O(pixels) regardless of buffer size (1h+ of data) +- X axis: scrolling window, seconds from configurable t0 +- Y axis: auto-fit or fixed range +- Material3 default theme, fully customizable via `ChartTheme` + +## Installation + +```kotlin +// settings.gradle.kts +include(":chart-realtime") +``` + +Or Maven local: +```bash +./gradlew :chart-realtime:publishToMavenLocal +``` + +```kotlin +// build.gradle.kts +implementation("eu.henesis:chart-realtime:0.1.0-SNAPSHOT") +``` + +## Quick start (3 lines) + +```kotlin +val state = remember { RealtimeChartState(ChartConfig()) } +LaunchedEffect(state) { + state.addSignal("ecg", SignalConfig(color = Color.Green)) + sensorFlow.collect { v -> state.push("ecg", System.currentTimeMillis(), v) } +} +RealtimeChart(state = state, modifier = Modifier.fillMaxSize()) +``` + +## Configuration + +```kotlin +ChartConfig( + xWindowSeconds = 10f, // visible window width + yRange = YRange.Auto(), // or YRange.Fixed(-1f, 1f) + t0 = T0.FirstSample, // or T0.Fixed(epochMs) + targetFps = null, // null = display refresh rate + maxBufferSeconds = 3600f, // 1h history per signal + theme = ChartTheme( // optional visual override + backgroundColor = Color(0xFF1A1A2E), + gridColor = Color(0x22FFFFFF), + axisColor = Color(0xFFBBBBBB), + ), +) +``` + +## Flow API + +```kotlin +// From Flow (auto-timestamp) +state.collectFrom("accel_x", accelerometerFlow, viewModelScope) + +// From Flow> (explicit timestamp) +state.collectFrom("ecg", ecgFlow, viewModelScope) // ecgFlow emits (timestampMs, value) +``` + +## Performance notes + +- Data thread pushes at any rate; render thread reads at display refresh (or `targetFps`) +- 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`) + +## Requirements + +- Android minSdk 26 +- Jetpack Compose / Compose Multiplatform 1.8+ +- Kotlin 2.1+ diff --git a/chart-realtime/build.gradle.kts b/chart-realtime/build.gradle.kts new file mode 100644 index 0000000..908aff5 --- /dev/null +++ b/chart-realtime/build.gradle.kts @@ -0,0 +1,58 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.library) + alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.compose.compiler) + `maven-publish` +} + +group = "dev.dtrentin" +version = "0.4.1-SNAPSHOT" + +kotlin { + androidTarget { + compilations.all { + compileTaskProvider.configure { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } + } + } + publishLibraryVariants("release") + } + + sourceSets { + commonMain.dependencies { + implementation(compose.runtime) + implementation(compose.ui) + implementation(compose.foundation) + implementation(compose.material3) + implementation(libs.coroutines.core) + } + androidMain.dependencies { + implementation(libs.androidx.core) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + } + } +} + +android { + namespace = "dev.dtrentin.chart" + compileSdk = 35 + + defaultConfig { + minSdk = 26 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +publishing { + // KMP plugin auto-creates per-target publications. + // Run: ./gradlew publishToMavenLocal +} diff --git a/chart-realtime/src/androidMain/AndroidManifest.xml b/chart-realtime/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..8072ee0 --- /dev/null +++ b/chart-realtime/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/chart-realtime/src/androidMain/kotlin/dev/dtrentin/chart/Platform.android.kt b/chart-realtime/src/androidMain/kotlin/dev/dtrentin/chart/Platform.android.kt new file mode 100644 index 0000000..fe867ed --- /dev/null +++ b/chart-realtime/src/androidMain/kotlin/dev/dtrentin/chart/Platform.android.kt @@ -0,0 +1,3 @@ +package dev.dtrentin.chart + +internal actual fun currentTimeMs(): Long = System.currentTimeMillis() diff --git a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/Platform.kt b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/Platform.kt new file mode 100644 index 0000000..b46563d --- /dev/null +++ b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/Platform.kt @@ -0,0 +1,3 @@ +package dev.dtrentin.chart + +internal expect fun currentTimeMs(): Long diff --git a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChart.kt b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChart.kt new file mode 100644 index 0000000..580f0ac --- /dev/null +++ b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChart.kt @@ -0,0 +1,90 @@ +package dev.dtrentin.chart + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.dp +import dev.dtrentin.chart.buffer.LodDecimator +import dev.dtrentin.chart.buffer.TieredBuffer +import dev.dtrentin.chart.model.AxisLabelMode +import dev.dtrentin.chart.model.ChartTheme +import dev.dtrentin.chart.render.AxisRenderer.drawXAxis +import dev.dtrentin.chart.render.AxisRenderer.drawYAxis +import dev.dtrentin.chart.render.AxisRenderer.resolveYRange +import dev.dtrentin.chart.render.SignalRenderer.drawSignal +import dev.dtrentin.chart.render.rememberFrameTick + +@Composable +fun RealtimeChart( + state: RealtimeChartState, + modifier: Modifier = Modifier, + xWindowSeconds: Float = state.config.xWindowSeconds, + theme: ChartTheme = state.config.theme, +) { + val config = state.config + val frameTick = rememberFrameTick(config.targetFps) + val textMeasurer = rememberTextMeasurer() + val density = LocalDensity.current + val chartLeftPx = remember(config.yLabelMode) { + if (config.yLabelMode == AxisLabelMode.BESIDE) with(density) { 52.dp.toPx() } else 0f + } + val chartBottomInsetPx = remember(config.xLabelMode) { + if (config.xLabelMode == AxisLabelMode.BESIDE) with(density) { 20.dp.toPx() } else 0f + } + val snapTs = remember { LongArray(TieredBuffer.TOTAL_CAPACITY) } + val snapV = remember { FloatArray(TieredBuffer.TOTAL_CAPACITY) } + val lodX = remember { FloatArray(TieredBuffer.TOTAL_CAPACITY) } + val lodY = remember { FloatArray(TieredBuffer.TOTAL_CAPACITY) } + val path = remember { Path() } + val lodDecimator = remember { LodDecimator() } + + Canvas(modifier = modifier.background(theme.backgroundColor)) { + @Suppress("UNUSED_EXPRESSION") frameTick + val signals = state.signals + if (signals.isEmpty() || state.resolvedT0Ms == null) return@Canvas + val windowMs = (xWindowSeconds * 1000f).toLong() + if (windowMs <= 0L) return@Canvas + + val chartBottom = size.height - chartBottomInsetPx + + var latestMs = Long.MIN_VALUE + for ((_, entry) in signals) { + val ts = entry.buffer.latestTimestampMs() + if (ts > latestMs) latestMs = ts + } + if (latestMs == Long.MIN_VALUE) return@Canvas + val windowStartMs = latestMs - windowMs + + var dataMin = Float.POSITIVE_INFINITY + var dataMax = Float.NEGATIVE_INFINITY + for ((_, entry) in signals) { + if (!entry.config.visible) continue + val n = entry.buffer.snapshot(windowStartMs, windowMs, snapTs, snapV) + for (i in 0 until n) { + if (snapV[i] < dataMin) dataMin = snapV[i] + if (snapV[i] > dataMax) dataMax = snapV[i] + } + } + if (dataMin == Float.POSITIVE_INFINITY) { dataMin = -1f; dataMax = 1f } + val (yMin, yMax) = resolveYRange(config, dataMin, dataMax) + + drawXAxis(windowStartMs, windowMs, theme, textMeasurer, config.xLabelMode, config.xLabelDecimals, chartLeftPx, chartBottom, state.resolvedT0Ms!!, showGrid = config.showGrid) + drawYAxis(yMin, yMax, theme, textMeasurer, config.yLabelMode, config.yLabelDecimals, chartLeftPx, chartBottom, showGrid = config.showGrid) + + for ((_, entry) in signals) { + drawSignal( + entry = entry, snapTs = snapTs, snapV = snapV, + lodX = lodX, lodY = lodY, path = path, + windowStartMs = windowStartMs, windowMs = windowMs, + yMin = yMin, yMax = yMax, + chartLeft = chartLeftPx, chartBottom = chartBottom, + lodDecimator = lodDecimator, mode = config.lodMode, + ) + } + } +} diff --git a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChartState.kt b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChartState.kt new file mode 100644 index 0000000..8e0b333 --- /dev/null +++ b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChartState.kt @@ -0,0 +1,65 @@ +package dev.dtrentin.chart + +import androidx.compose.runtime.mutableStateOf +import dev.dtrentin.chart.buffer.TieredBuffer +import dev.dtrentin.chart.model.ChartConfig +import dev.dtrentin.chart.model.SignalConfig +import dev.dtrentin.chart.model.T0 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +/** + * Holds all state for a [RealtimeChart]. Create once, pass to composable. + * + * Data ingestion is thread-safe. Compose reads on main thread. + * + * ``` + * val state = remember { RealtimeChartState(ChartConfig()) } + * state.addSignal("v1", SignalConfig(color = Color.Red)) + * RealtimeChart(state, Modifier.fillMaxSize()) + * ``` + */ +class RealtimeChartState( + val config: ChartConfig = ChartConfig(), +) { + private val _signals = mutableStateOf>(emptyMap()) + internal val signals: Map get() = _signals.value + + @Volatile internal var resolvedT0Ms: Long? = when (val t = config.t0) { + is T0.Fixed -> t.epochMs + T0.FirstSample -> null + } + + /** Registers a signal. Must call before [push]. If [name] exists, buffer is replaced. */ + fun addSignal(name: String, signalConfig: SignalConfig) { + _signals.value = _signals.value + (name to SignalEntry(signalConfig, TieredBuffer())) + } + + /** Removes signal [name] and its buffer. No-op if absent. */ + fun removeSignal(name: String) { + _signals.value = _signals.value - name + } + + /** Pushes one sample. Thread-safe. @param timestampMs ms epoch. */ + fun push(name: String, timestampMs: Long, value: Float) { + if (resolvedT0Ms == null) resolvedT0Ms = timestampMs + _signals.value[name]?.buffer?.push(timestampMs, value) + } + + /** Collects (timestampMs, value) pairs from [flow] into signal [name]. Returns [Job]. */ + @JvmName("collectFromTimestamped") + fun collectFrom(name: String, flow: Flow>, scope: CoroutineScope): Job = + scope.launch { flow.collect { (ts, v) -> push(name, ts, v) } } + + /** Collects raw [Float] from [flow], stamps with current time. Returns [Job]. */ + @JvmName("collectFromFloat") + fun collectFrom(name: String, flow: Flow, scope: CoroutineScope): Job = + scope.launch { flow.collect { v -> push(name, currentTimeMs(), v) } } +} + +internal class SignalEntry( + val config: SignalConfig, + val buffer: TieredBuffer, +) diff --git a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/buffer/CircularBuffer.kt b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/buffer/CircularBuffer.kt new file mode 100644 index 0000000..19d7bb7 --- /dev/null +++ b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/buffer/CircularBuffer.kt @@ -0,0 +1,49 @@ +package dev.dtrentin.chart.buffer + +/** + * Lock-free circular buffer for (timestampMs, value) sensor samples. + * Pre-allocated — zero allocations after construction. + * Thread safety: single writer, single reader. @Volatile for visibility. + */ +class CircularBuffer(val capacity: Int) { + + private val timestamps = LongArray(capacity) + private val values = FloatArray(capacity) + + @Volatile private var writeIndex = 0 + @Volatile private var size = 0 + + fun push(timestampMs: Long, value: Float) { + val idx = writeIndex % capacity + timestamps[idx] = timestampMs + values[idx] = value + writeIndex++ + if (size < capacity) size++ + } + + /** + * Snapshot into caller-owned arrays (must be >= capacity length). + * Returns count of valid samples written. Oldest at index 0, newest at count-1. + */ + fun snapshot(outTimestamps: LongArray, outValues: FloatArray): Int { + val currentWrite = writeIndex + val currentSize = size.coerceAtMost(capacity) + if (currentSize == 0) return 0 + val startIdx = if (currentSize < capacity) 0 else currentWrite - capacity + for (i in 0 until currentSize) { + val src = ((startIdx + i) % capacity + capacity) % capacity + outTimestamps[i] = timestamps[src] + outValues[i] = values[src] + } + return currentSize + } + + fun latestTimestampMs(): Long { + if (size == 0) return -1L + return timestamps[(writeIndex - 1 + capacity) % capacity] + } + + fun clear() { writeIndex = 0; size = 0 } + + val currentSize: Int get() = size.coerceAtMost(capacity) +} diff --git a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/buffer/LodDecimator.kt b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/buffer/LodDecimator.kt new file mode 100644 index 0000000..3dd02ba --- /dev/null +++ b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/buffer/LodDecimator.kt @@ -0,0 +1,138 @@ +package dev.dtrentin.chart.buffer + +import dev.dtrentin.chart.model.LodMode + +class LodDecimator( + maxCount: Int = TieredBuffer.TOTAL_CAPACITY, + maxPixelWidth: Int = 2048, +) { + private val colArr = IntArray(maxCount) + private val valArr = FloatArray(maxCount) + private val colMin = FloatArray(maxPixelWidth) + private val colMax = FloatArray(maxPixelWidth) + private val colHit = BooleanArray(maxPixelWidth) + private val bucketSumY = DoubleArray(maxPixelWidth) + private val bucketCountArr = IntArray(maxPixelWidth) + private val nextNonEmpty = IntArray(maxPixelWidth + 1) + + fun decimate( + timestamps: LongArray, + values: FloatArray, + count: Int, + windowStartMs: Long, + windowMs: Long, + pixelWidth: Int, + outX: FloatArray, + outY: FloatArray, + mode: LodMode = LodMode.MIN_MAX, + ): Int { + if (count == 0 || pixelWidth <= 0 || windowMs <= 0) return 0 + + var n = 0 + for (i in 0 until count) { + val tRelMs = timestamps[i] - windowStartMs + if (tRelMs < 0 || tRelMs > windowMs) continue + colArr[n] = (tRelMs.toFloat() / windowMs.toFloat() * pixelWidth).toInt().coerceIn(0, pixelWidth - 1) + valArr[n] = values[i] + n++ + } + if (n == 0) return 0 + + if (n <= pixelWidth) { + for (i in 0 until n) { outX[i] = colArr[i] + 0.5f; outY[i] = valArr[i] } + return n + } + + return when (mode) { + LodMode.MEAN -> decimateMean(n, pixelWidth, outX, outY) + LodMode.MIN_MAX -> decimateMinMax(n, pixelWidth, outX, outY) + LodMode.LTTB -> decimateLttb(n, pixelWidth, outX, outY) + } + } + + private fun decimateMean(n: Int, pixelWidth: Int, outX: FloatArray, outY: FloatArray): Int { + for (col in 0 until pixelWidth) { bucketSumY[col] = 0.0; bucketCountArr[col] = 0 } + for (i in 0 until n) { bucketSumY[colArr[i]] += valArr[i]; bucketCountArr[colArr[i]]++ } + var outIdx = 0 + for (col in 0 until pixelWidth) { + if (bucketCountArr[col] == 0) continue + outX[outIdx] = col + 0.5f + outY[outIdx] = (bucketSumY[col] / bucketCountArr[col]).toFloat() + outIdx++ + } + return outIdx + } + + private fun decimateMinMax(n: Int, pixelWidth: Int, outX: FloatArray, outY: FloatArray): Int { + for (col in 0 until pixelWidth) { colMin[col] = Float.MAX_VALUE; colMax[col] = -Float.MAX_VALUE; colHit[col] = false } + for (i in 0 until n) { + val col = colArr[i]; val v = valArr[i] + if (v < colMin[col]) colMin[col] = v + if (v > colMax[col]) colMax[col] = v + colHit[col] = true + } + var outIdx = 0 + for (col in 0 until pixelWidth) { + if (!colHit[col]) continue + val xCenter = col + 0.5f + outX[outIdx] = xCenter; outY[outIdx] = colMin[col]; outIdx++ + outX[outIdx] = xCenter; outY[outIdx] = colMax[col]; outIdx++ + } + return outIdx + } + + private fun decimateLttb(n: Int, pixelWidth: Int, outX: FloatArray, outY: FloatArray): Int { + for (col in 0 until pixelWidth) { bucketSumY[col] = 0.0; bucketCountArr[col] = 0 } + for (i in 0 until n) { bucketSumY[colArr[i]] += valArr[i]; bucketCountArr[colArr[i]]++ } + + nextNonEmpty[pixelWidth] = -1 + for (col in pixelWidth - 1 downTo 0) { + nextNonEmpty[col] = if (bucketCountArr[col] > 0) col else nextNonEmpty[col + 1] + } + + var outIdx = 0 + var prevX = 0f + var prevY = 0f + var firstDone = false + var samplePtr = 0 + + for (col in 0 until pixelWidth) { + val cnt = bucketCountArr[col] + if (cnt == 0) continue + val colX = col + 0.5f + val bucketEnd = samplePtr + cnt + + if (!firstDone) { + outX[outIdx] = colX; outY[outIdx] = valArr[samplePtr] + prevX = colX; prevY = valArr[samplePtr] + outIdx++; firstDone = true + samplePtr = bucketEnd + continue + } + + val nextCol = nextNonEmpty[col + 1] + if (nextCol < 0) { + outX[outIdx] = colX; outY[outIdx] = valArr[bucketEnd - 1] + outIdx++ + samplePtr = bucketEnd + continue + } + + val nextAvgX = nextCol + 0.5f + val nextAvgY = (bucketSumY[nextCol] / bucketCountArr[nextCol]).toFloat() + + var bestArea = -1f + var bestVal = valArr[samplePtr] + for (i in samplePtr until bucketEnd) { + val area = kotlin.math.abs((prevX - nextAvgX) * (valArr[i] - prevY) - (prevX - colX) * (nextAvgY - prevY)) + if (area > bestArea) { bestArea = area; bestVal = valArr[i] } + } + outX[outIdx] = colX; outY[outIdx] = bestVal + prevX = colX; prevY = bestVal + outIdx++ + samplePtr = bucketEnd + } + + return outIdx + } +} diff --git a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/buffer/TieredBuffer.kt b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/buffer/TieredBuffer.kt new file mode 100644 index 0000000..1f0079e --- /dev/null +++ b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/buffer/TieredBuffer.kt @@ -0,0 +1,139 @@ +package dev.dtrentin.chart.buffer + +class TieredBuffer { + + private val tier0 = CircularBuffer(TIER0_CAPACITY) + private val tier1 = CircularBuffer(TIER1_CAPACITY) + private val tier2 = CircularBuffer(TIER2_CAPACITY) + + private var tier1BinStartMs = -1L + private var tier1BinSum = 0.0 + private var tier1BinCount = 0 + + private var tier2BinStartMs = -1L + private var tier2BinSum = 0.0 + private var tier2BinCount = 0 + + private val t0Ts = LongArray(TIER0_CAPACITY) + private val t0Vs = FloatArray(TIER0_CAPACITY) + private val t1Ts = LongArray(TIER1_CAPACITY) + private val t1Vs = FloatArray(TIER1_CAPACITY) + private val t2Ts = LongArray(TIER2_CAPACITY) + private val t2Vs = FloatArray(TIER2_CAPACITY) + + fun push(timestampMs: Long, value: Float) { + tier0.push(timestampMs, value) + feedTier1(timestampMs, value) + feedTier2(timestampMs, value) + } + + private fun feedTier1(ts: Long, value: Float) { + if (tier1BinStartMs < 0L) { + tier1BinStartMs = (ts / TIER1_BIN_MS) * TIER1_BIN_MS + } + if (ts >= tier1BinStartMs + TIER1_BIN_MS) { + if (tier1BinCount > 0) { + tier1.push(tier1BinStartMs + TIER1_BIN_MS / 2L, (tier1BinSum / tier1BinCount).toFloat()) + } + tier1BinStartMs = (ts / TIER1_BIN_MS) * TIER1_BIN_MS + tier1BinSum = value.toDouble() + tier1BinCount = 1 + } else { + tier1BinSum += value + tier1BinCount++ + } + } + + private fun feedTier2(ts: Long, value: Float) { + if (tier2BinStartMs < 0L) { + tier2BinStartMs = (ts / TIER2_BIN_MS) * TIER2_BIN_MS + } + if (ts >= tier2BinStartMs + TIER2_BIN_MS) { + if (tier2BinCount > 0) { + tier2.push(tier2BinStartMs + TIER2_BIN_MS / 2L, (tier2BinSum / tier2BinCount).toFloat()) + } + tier2BinStartMs = (ts / TIER2_BIN_MS) * TIER2_BIN_MS + tier2BinSum = value.toDouble() + tier2BinCount = 1 + } else { + tier2BinSum += value + tier2BinCount++ + } + } + + fun latestTimestampMs(): Long = tier0.latestTimestampMs() + + fun snapshot( + windowStartMs: Long, + windowMs: Long, + outTimestamps: LongArray, + outValues: FloatArray, + ): Int { + val nowMs = tier0.latestTimestampMs() + if (nowMs < 0L) return 0 + + val windowEndMs = windowStartMs + windowMs + val tier0BoundaryMs = nowMs - TIER0_DURATION_MS + val tier1BoundaryMs = nowMs - TIER0_DURATION_MS - TIER1_DURATION_MS + + var outIdx = 0 + + if (windowStartMs < tier1BoundaryMs) { + val n2 = tier2.snapshot(t2Ts, t2Vs) + for (i in 0 until n2) { + val ts = t2Ts[i] + if (ts >= windowStartMs && ts < windowEndMs && ts < tier1BoundaryMs) { + if (outIdx >= outTimestamps.size) break + outTimestamps[outIdx] = ts; outValues[outIdx] = t2Vs[i]; outIdx++ + } + } + } + + if (windowStartMs < tier0BoundaryMs && windowEndMs > tier1BoundaryMs) { + val n1 = tier1.snapshot(t1Ts, t1Vs) + for (i in 0 until n1) { + val ts = t1Ts[i] + if (ts >= windowStartMs && ts < windowEndMs && ts >= tier1BoundaryMs && ts < tier0BoundaryMs) { + if (outIdx >= outTimestamps.size) break + outTimestamps[outIdx] = ts; outValues[outIdx] = t1Vs[i]; outIdx++ + } + } + } + + val tier0Start = maxOf(windowStartMs, tier0BoundaryMs) + val n0 = tier0.snapshot(t0Ts, t0Vs) + for (i in 0 until n0) { + val ts = t0Ts[i] + if (ts >= tier0Start && ts < windowEndMs) { + if (outIdx >= outTimestamps.size) break + outTimestamps[outIdx] = ts; outValues[outIdx] = t0Vs[i]; outIdx++ + } + } + + return outIdx + } + + fun clear() { + tier0.clear(); tier1.clear(); tier2.clear() + tier1BinStartMs = -1L; tier1BinSum = 0.0; tier1BinCount = 0 + tier2BinStartMs = -1L; tier2BinSum = 0.0; tier2BinCount = 0 + } + + companion object { + const val TIER0_MAX_HZ = 200 + const val TIER1_HZ = 10 + const val TIER2_HZ = 1 + + const val TIER0_DURATION_MS = 5L * 60_000L + const val TIER1_DURATION_MS = 10L * 60_000L + const val TIER2_DURATION_MS = 45L * 60_000L + + const val TIER1_BIN_MS = 1000L / TIER1_HZ + const val TIER2_BIN_MS = 1000L / TIER2_HZ + + val TIER0_CAPACITY = ((TIER0_DURATION_MS / 1000L) * TIER0_MAX_HZ).toInt() + val TIER1_CAPACITY = ((TIER1_DURATION_MS / 1000L) * TIER1_HZ).toInt() + val TIER2_CAPACITY = ((TIER2_DURATION_MS / 1000L) * TIER2_HZ).toInt() + val TOTAL_CAPACITY = TIER0_CAPACITY + TIER1_CAPACITY + TIER2_CAPACITY + } +} 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 new file mode 100644 index 0000000..8a34287 --- /dev/null +++ b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/AxisLabelMode.kt @@ -0,0 +1,7 @@ +package dev.dtrentin.chart.model + +enum class AxisLabelMode { + INSIDE, // labels drawn over chart data area (default) + BESIDE, // labels in reserved margin outside chart data area + HIDDEN, // no labels +} 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 new file mode 100644 index 0000000..5025657 --- /dev/null +++ b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/ChartConfig.kt @@ -0,0 +1,26 @@ +package dev.dtrentin.chart.model + +/** + * Top-level chart configuration. + * + * @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 maxBufferSeconds Max history kept per signal. Caller is responsible for memory implications. + * @param theme Visual theme (colors, stroke widths). + */ +data class ChartConfig( + val xWindowSeconds: Float = 10f, + val yRange: YRange = YRange.Auto(), + val t0: T0 = T0.FirstSample, + val targetFps: Int? = null, + val maxBufferSeconds: Float = 60f, + val theme: ChartTheme = ChartTheme(), + val showGrid: Boolean = true, + val xLabelMode: AxisLabelMode = AxisLabelMode.INSIDE, + val yLabelMode: AxisLabelMode = AxisLabelMode.INSIDE, + val yLabelDecimals: Int = 2, + val xLabelDecimals: Int = 1, + val lodMode: LodMode = LodMode.LTTB, +) diff --git a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/ChartTheme.kt b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/ChartTheme.kt new file mode 100644 index 0000000..de8c462 --- /dev/null +++ b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/ChartTheme.kt @@ -0,0 +1,37 @@ +package dev.dtrentin.chart.model + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color + +/** + * Visual theme for chart canvas. + * + * @param backgroundColor Canvas background fill. + * @param gridColor Grid lines (X/Y ticks). + * @param axisColor Axis lines (left/bottom borders). + * @param labelColor Axis tick label text color. + * @param strokeWidth Axis line stroke width in px. + */ +data class ChartTheme( + val backgroundColor: Color = Color(0xFF1A1A2E), + val gridColor: Color = Color(0x66FFFFFF), + val axisColor: Color = Color(0xFFBBBBBB), + val labelColor: Color = Color(0xFFBBBBBB), + val strokeWidth: Float = 1.5f, +) + +@Composable +fun rememberMaterialChartTheme(): ChartTheme { + val cs = MaterialTheme.colorScheme + return remember(cs) { + ChartTheme( + backgroundColor = cs.surface, + gridColor = cs.outlineVariant.copy(alpha = 0.65f), + axisColor = cs.outline, + labelColor = cs.onSurface, + strokeWidth = 1.5f, + ) + } +} 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 new file mode 100644 index 0000000..11fe8bd --- /dev/null +++ b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/LodMode.kt @@ -0,0 +1,3 @@ +package dev.dtrentin.chart.model + +enum class LodMode { MEAN, MIN_MAX, LTTB } diff --git a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/SignalConfig.kt b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/SignalConfig.kt new file mode 100644 index 0000000..84d4dd2 --- /dev/null +++ b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/SignalConfig.kt @@ -0,0 +1,18 @@ +package dev.dtrentin.chart.model + +import androidx.compose.ui.graphics.Color + +/** + * Per-signal display configuration. + * + * @param color Line color. + * @param strokeWidth Line width in dp. + * @param visible If false, signal is skipped during rendering. + * @param label Optional display label for legend (future use). + */ +data class SignalConfig( + val color: Color, + val strokeWidth: Float = 2f, + val visible: Boolean = true, + val label: String? = null, +) diff --git a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/T0.kt b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/T0.kt new file mode 100644 index 0000000..f652da4 --- /dev/null +++ b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/T0.kt @@ -0,0 +1,13 @@ +package dev.dtrentin.chart.model + +/** Origin of X axis (t=0 seconds). */ +sealed class T0 { + /** Use timestamp of first sample ever pushed as t=0. */ + object FirstSample : T0() + + /** + * Use a fixed epoch timestamp as t=0. + * @param epochMs absolute timestamp in milliseconds (e.g. System.currentTimeMillis()). + */ + data class Fixed(val epochMs: Long) : T0() +} diff --git a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/YRange.kt b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/YRange.kt new file mode 100644 index 0000000..c0d77c2 --- /dev/null +++ b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/YRange.kt @@ -0,0 +1,17 @@ +package dev.dtrentin.chart.model + +/** Y axis range strategy. */ +sealed class YRange { + /** + * Auto-fit to data visible in current X window. + * @param paddingFraction fractional padding added above and below data range (default 10%). + */ + data class Auto(val paddingFraction: Float = 0.1f) : YRange() + + /** + * Fixed range regardless of data. + */ + data class Fixed(val min: Float, val max: Float) : YRange() { + init { require(min < max) { "YRange.Fixed: min ($min) must be < max ($max)" } } + } +} diff --git a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/render/AxisRenderer.kt b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/render/AxisRenderer.kt new file mode 100644 index 0000000..383e909 --- /dev/null +++ b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/render/AxisRenderer.kt @@ -0,0 +1,185 @@ +package dev.dtrentin.chart.render + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.sp +import dev.dtrentin.chart.model.AxisLabelMode +import dev.dtrentin.chart.model.ChartConfig +import dev.dtrentin.chart.model.ChartTheme +import dev.dtrentin.chart.model.YRange +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.log10 +import kotlin.math.pow + +internal object AxisRenderer { + + fun DrawScope.drawXAxis( + windowStartMs: Long, + windowMs: Long, + theme: ChartTheme, + textMeasurer: TextMeasurer, + labelMode: AxisLabelMode, + labelDecimals: Int, + chartLeft: Float, + chartBottom: Float, + t0Ms: Long, + showGrid: Boolean = true, + ) { + val w = size.width + val chartW = w - chartLeft + if (windowMs <= 0 || chartW <= 0) return + + val windowSec = windowMs / 1000.0 + val tickInterval = niceInterval(windowSec, targetTickCount = 6) + var tickSec = floor((windowStartMs / 1000.0) / tickInterval) * tickInterval + val windowEndSec = (windowStartMs + windowMs) / 1000.0 + + while (tickSec <= windowEndSec + tickInterval * 0.01) { + val fracX = ((tickSec - windowStartMs / 1000.0) / windowSec).toFloat() + val xPx = chartLeft + fracX * chartW + if (xPx in chartLeft..w) { + if (showGrid) drawLine(theme.gridColor, Offset(xPx, 0f), Offset(xPx, chartBottom), strokeWidth = 1f) + if (labelMode != AxisLabelMode.HIDDEN) { + val label = formatTimeSec(tickSec - t0Ms / 1000.0) + when (labelMode) { + AxisLabelMode.INSIDE -> { + val availableW = w - xPx - 6f + if (availableW > 4f) { + drawText( + textMeasurer, label, + topLeft = Offset(xPx + 4f, chartBottom - 18f), + style = TextStyle(color = theme.labelColor, fontSize = 9.sp), + size = Size(availableW, with(this) { 12.sp.toPx() }), + ) + } + } + AxisLabelMode.BESIDE -> { + val labelH = size.height - chartBottom + if (labelH > 2f) { + val maxW = (chartW * tickInterval / windowSec).toFloat().coerceAtMost(chartW / 2f) + val labelX = xPx - maxW / 2f + if (labelX >= chartLeft) { + drawText( + textMeasurer, label, + topLeft = Offset(labelX, chartBottom + 6f), + style = TextStyle(color = theme.labelColor, fontSize = 9.sp, textAlign = TextAlign.Center), + size = Size(maxW, labelH - 6f), + ) + } + } + } + AxisLabelMode.HIDDEN -> Unit + } + } + } + tickSec += tickInterval + } + drawLine(theme.axisColor, Offset(chartLeft, chartBottom), Offset(w, chartBottom), strokeWidth = theme.strokeWidth) + } + + fun DrawScope.drawYAxis( + yMin: Float, + yMax: Float, + theme: ChartTheme, + textMeasurer: TextMeasurer, + labelMode: AxisLabelMode, + labelDecimals: Int, + chartLeft: Float, + chartBottom: Float, + showGrid: Boolean = true, + ) { + val w = size.width + val chartW = w - chartLeft + if (yMax <= yMin || chartW <= 0) return + + val range = (yMax - yMin).toDouble() + val tickInterval = niceInterval(range, targetTickCount = 5) + var tick = ceil(yMin / tickInterval) * tickInterval + + while (tick <= yMax + tickInterval * 0.01) { + val fracY = 1f - ((tick - yMin) / (yMax - yMin)).toFloat() + val yPx = fracY * chartBottom + if (yPx in 0f..chartBottom) { + if (showGrid) drawLine(theme.gridColor, Offset(chartLeft, yPx), Offset(w, yPx), strokeWidth = 1f) + if (labelMode != AxisLabelMode.HIDDEN) { + val label = formatAxisValue(tick, labelDecimals) + when (labelMode) { + AxisLabelMode.INSIDE -> { + val labelY = (yPx - 10f).coerceAtMost(chartBottom - 14f) + if (labelY >= 0f && chartW > 4f) { + drawText( + textMeasurer, label, + topLeft = Offset(chartLeft + 6f, labelY), + style = TextStyle(color = theme.labelColor, fontSize = 9.sp), + size = Size(chartW - 8f, with(this) { 12.sp.toPx() }), + ) + } + } + AxisLabelMode.BESIDE -> { + if (chartLeft > 4f) { + val labelY = (yPx - 7f).coerceIn(0f, chartBottom - 14f) + drawText( + textMeasurer, label, + topLeft = Offset(2f, labelY), + style = TextStyle(color = theme.labelColor, fontSize = 9.sp, textAlign = TextAlign.End), + size = Size(chartLeft - 12f, with(this) { 12.sp.toPx() }), + ) + } + } + AxisLabelMode.HIDDEN -> Unit + } + } + } + tick += tickInterval + } + drawLine(theme.axisColor, Offset(chartLeft, 0f), Offset(chartLeft, chartBottom), strokeWidth = theme.strokeWidth) + } + + fun resolveYRange(config: ChartConfig, dataMin: Float, dataMax: Float): Pair = + when (val yr = config.yRange) { + is YRange.Fixed -> yr.min to yr.max + is YRange.Auto -> { + val range = (dataMax - dataMin).coerceAtLeast(1e-6f) + val pad = range * yr.paddingFraction + (dataMin - pad) to (dataMax + pad) + } + } + + private fun formatTimeSec(totalSec: Double): String { + val s = totalSec.toLong().coerceAtLeast(0L) + return when { + s < 60L -> "${s}s" + s < 3600L -> "${s / 60}:${(s % 60).toString().padStart(2, '0')}" + else -> "${s / 3600}:${((s % 3600) / 60).toString().padStart(2, '0')}:${(s % 60).toString().padStart(2, '0')}" + } + } + + private fun formatAxisValue(value: Double, decimals: Int): String { + val absVal = kotlin.math.abs(value) + return if (value == 0.0) { + "0" + } else if (absVal < 0.01 || absVal >= 1e5) { + "%.${decimals}e".format(value) + } else { + "%.${decimals}f".format(value) + } + } + + private fun niceInterval(range: Double, targetTickCount: Int): Double { + if (range <= 0) return 1.0 + val rough = range / targetTickCount + val mag = 10.0.pow(floor(log10(rough))) + return when { + rough / mag < 1.5 -> mag + rough / mag < 3.5 -> 2.0 * mag + rough / mag < 7.5 -> 5.0 * mag + else -> 10.0 * mag + } + } +} diff --git a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/render/RenderLoop.kt b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/render/RenderLoop.kt new file mode 100644 index 0000000..4275a36 --- /dev/null +++ b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/render/RenderLoop.kt @@ -0,0 +1,32 @@ +package dev.dtrentin.chart.render + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos + +/** + * Returns a monotonically increasing frame counter. + * Increments at display refresh rate, or capped to targetFps if non-null. + */ +@Composable +fun rememberFrameTick(targetFps: Int? = null): Long { + var tick by remember { mutableLongStateOf(0L) } + val minFrameNanos = if (targetFps != null && targetFps > 0) 1_000_000_000L / targetFps else 0L + + LaunchedEffect(targetFps) { + var lastFrameNanos = 0L + while (true) { + withFrameNanos { frameNanos -> + if (frameNanos - lastFrameNanos >= minFrameNanos) { + tick++ + lastFrameNanos = frameNanos + } + } + } + } + return tick +} diff --git a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/render/SignalRenderer.kt b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/render/SignalRenderer.kt new file mode 100644 index 0000000..877b103 --- /dev/null +++ b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/render/SignalRenderer.kt @@ -0,0 +1,53 @@ +package dev.dtrentin.chart.render + +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.clipRect +import dev.dtrentin.chart.SignalEntry +import dev.dtrentin.chart.buffer.LodDecimator +import dev.dtrentin.chart.model.LodMode + +internal object SignalRenderer { + + fun DrawScope.drawSignal( + entry: SignalEntry, + snapTs: LongArray, + snapV: FloatArray, + lodX: FloatArray, + lodY: FloatArray, + path: Path, + windowStartMs: Long, + windowMs: Long, + yMin: Float, + yMax: Float, + chartLeft: Float, + chartBottom: Float, + lodDecimator: LodDecimator, + mode: LodMode, + ) { + if (!entry.config.visible) return + val count = entry.buffer.snapshot(windowStartMs, windowMs, snapTs, snapV) + if (count == 0) return + val chartW = size.width - chartLeft + val pixelWidth = chartW.toInt().coerceAtLeast(1) + if (chartBottom <= 0f || chartW <= 0f) return + val pairs = lodDecimator.decimate( + timestamps = snapTs, values = snapV, count = count, + windowStartMs = windowStartMs, windowMs = windowMs, + pixelWidth = pixelWidth, outX = lodX, outY = lodY, + mode = mode, + ) + if (pairs == 0) return + val yRange = (yMax - yMin).coerceAtLeast(1e-6f) + val invY = 1f / yRange + path.reset() + path.moveTo(chartLeft + lodX[0], chartBottom - ((lodY[0] - yMin) * invY) * chartBottom) + for (i in 1 until pairs) { + path.lineTo(chartLeft + lodX[i], chartBottom - ((lodY[i] - yMin) * invY) * chartBottom) + } + clipRect(left = chartLeft, top = 0f, right = size.width, bottom = chartBottom) { + drawPath(path = path, color = entry.config.color, style = Stroke(width = entry.config.strokeWidth)) + } + } +} diff --git a/chart-realtime/src/commonTest/kotlin/dev/dtrentin/chart/buffer/CircularBufferTest.kt b/chart-realtime/src/commonTest/kotlin/dev/dtrentin/chart/buffer/CircularBufferTest.kt new file mode 100644 index 0000000..671ab05 --- /dev/null +++ b/chart-realtime/src/commonTest/kotlin/dev/dtrentin/chart/buffer/CircularBufferTest.kt @@ -0,0 +1,81 @@ +package dev.dtrentin.chart.buffer + +import kotlin.test.* + +class CircularBufferTest { + + @Test fun emptyBuffer_snapshotReturnsZero() { + val buf = CircularBuffer(10) + assertEquals(0, buf.snapshot(LongArray(10), FloatArray(10))) + } + + @Test fun pushFewerThanCapacity_correctCountAndOrder() { + val buf = CircularBuffer(10) + buf.push(1L, 1f); buf.push(2L, 2f); buf.push(3L, 3f) + val ts = LongArray(10); val v = FloatArray(10) + val count = buf.snapshot(ts, v) + assertEquals(3, count) + assertEquals(1L, ts[0]); assertEquals(1f, v[0]) + assertEquals(3L, ts[2]); assertEquals(3f, v[2]) + } + + @Test fun pushExactlyCapacity_returnsCapacityInOrder() { + val cap = 5; val buf = CircularBuffer(cap) + for (i in 0 until cap) buf.push(i.toLong(), i.toFloat()) + val ts = LongArray(cap); val v = FloatArray(cap) + assertEquals(cap, buf.snapshot(ts, v)) + for (i in 0 until cap) { assertEquals(i.toLong(), ts[i]); assertEquals(i.toFloat(), v[i]) } + } + + @Test fun wrapAround_oldestDiscarded() { + val cap = 5; val buf = CircularBuffer(cap) + for (i in 0..6) buf.push(i.toLong(), i.toFloat()) + val ts = LongArray(cap); val v = FloatArray(cap) + val count = buf.snapshot(ts, v) + assertEquals(cap, count) + assertEquals(2L, ts[0]); assertEquals(6L, ts[4]) + } + + @Test fun timestampsPreserved() { + val buf = CircularBuffer(3) + buf.push(100L, 0f); buf.push(200L, 0f); buf.push(300L, 0f) + val ts = LongArray(3); val v = FloatArray(3) + buf.snapshot(ts, v) + assertContentEquals(longArrayOf(100L, 200L, 300L), ts) + } + + @Test fun valuesPreserved() { + val buf = CircularBuffer(3) + buf.push(0L, 1.5f); buf.push(0L, 2.5f); buf.push(0L, 3.5f) + val ts = LongArray(3); val v = FloatArray(3) + buf.snapshot(ts, v) + assertContentEquals(floatArrayOf(1.5f, 2.5f, 3.5f), v) + } + + @Test fun clear_resetsToEmpty() { + val buf = CircularBuffer(5) + buf.push(1L, 1f); buf.push(2L, 2f) + buf.clear() + assertEquals(0, buf.snapshot(LongArray(5), FloatArray(5))) + assertEquals(0, buf.currentSize) + } + + @Test fun singleItem_pushAndSnapshot() { + val buf = CircularBuffer(10) + buf.push(42L, 9.9f) + val ts = LongArray(10); val v = FloatArray(10) + val count = buf.snapshot(ts, v) + assertEquals(1, count) + assertEquals(42L, ts[0]); assertEquals(9.9f, v[0]) + } + + @Test fun currentSize_updatesCorrectly() { + val buf = CircularBuffer(4) + assertEquals(0, buf.currentSize) + buf.push(1L, 0f); assertEquals(1, buf.currentSize) + buf.push(2L, 0f); buf.push(3L, 0f); buf.push(4L, 0f) + assertEquals(4, buf.currentSize) + buf.push(5L, 0f) + assertEquals(4, buf.currentSize) + } +} diff --git a/chart-realtime/src/commonTest/kotlin/dev/dtrentin/chart/buffer/LodDecimatorTest.kt b/chart-realtime/src/commonTest/kotlin/dev/dtrentin/chart/buffer/LodDecimatorTest.kt new file mode 100644 index 0000000..391bf38 --- /dev/null +++ b/chart-realtime/src/commonTest/kotlin/dev/dtrentin/chart/buffer/LodDecimatorTest.kt @@ -0,0 +1,59 @@ +package dev.dtrentin.chart.buffer + +import dev.dtrentin.chart.model.LodMode +import kotlin.test.* + +class LodDecimatorTest { + + private val decimator = LodDecimator() + private val outX = FloatArray(4096) + private val outY = FloatArray(4096) + + @Test fun emptyInput_returnsZero() { + assertEquals(0, decimator.decimate(LongArray(0), FloatArray(0), 0, 0L, 1000L, 100, outX, outY)) + } + + @Test fun zeroPixelWidth_returnsZero() { + assertEquals(0, decimator.decimate(longArrayOf(0L), floatArrayOf(1f), 1, 0L, 1000L, 0, outX, outY)) + } + + @Test fun zeroWindowMs_returnsZero() { + assertEquals(0, decimator.decimate(longArrayOf(0L), floatArrayOf(1f), 1, 0L, 0L, 100, outX, outY)) + } + + @Test fun fewerSamplesThanPixels_passThroughCount() { + val ts = longArrayOf(0L, 500L, 1000L) + val v = floatArrayOf(1f, 2f, 3f) + val count = decimator.decimate(ts, v, 3, 0L, 1000L, 100, outX, outY) + assertEquals(3, count) + } + + @Test fun fewerSamplesThanPixels_valuesPreserved() { + val ts = longArrayOf(0L, 500L, 1000L) + val v = floatArrayOf(1f, 2f, 3f) + decimator.decimate(ts, v, 3, 0L, 1000L, 100, outX, outY) + assertEquals(1f, outY[0]) + assertEquals(2f, outY[1]) + assertEquals(3f, outY[2]) + } + + @Test fun moreSamplesThanPixels_outputAtMost2xPixels() { + val n = 1000 + val ts = LongArray(n) { it.toLong() } + val v = FloatArray(n) { it.toFloat() } + val pixels = 50 + val count = decimator.decimate(ts, v, n, 0L, n.toLong(), pixels, outX, outY, mode = LodMode.MIN_MAX) + assertTrue(count <= pixels * 2, "count=$count > ${pixels * 2}") + assertTrue(count > 0) + } + + @Test fun decimation_minMaxPreservedInSingleColumn() { + val n = 10 + val ts = LongArray(n) { it.toLong() } + val v = FloatArray(n) { (it + 1).toFloat() } + val count = decimator.decimate(ts, v, n, 0L, 1000L, 100, outX, outY, mode = LodMode.MIN_MAX) + val yOut = outY.copyOf(count).toSet() + assertTrue(yOut.contains(1f), "min=1 missing, got $yOut") + assertTrue(yOut.contains(10f), "max=10 missing, got $yOut") + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..aa064a1 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,10 @@ +org.gradle.java.home=/home/dtrentin/.local/share/JetBrains/Toolbox/apps/android-studio/jbr +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.jvmargs=-Xmx4g -XX:+UseParallelGC + +kotlin.code.style=official +kotlin.incremental=true + +android.useAndroidX=true +android.nonTransitiveRClass=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..41df7b4 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,33 @@ +[versions] +kotlin = "2.1.0" +compose-multiplatform = "1.8.0" +coroutines = "1.9.0" +android-gradle = "8.7.3" +androidx-core = "1.15.0" +junit5 = "5.11.4" +kotlin-test = "2.1.0" +activity-compose = "1.10.1" +androidx-compose-bom = "2025.01.01" +material3 = "1.3.1" + +[libraries] +compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-multiplatform" } +compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-multiplatform" } +compose-ui-graphics = { module = "org.jetbrains.compose.ui:ui-graphics", version.ref = "compose-multiplatform" } +compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose-multiplatform" } +coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin-test" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" } +androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "material3" } + +[plugins] +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +android-library = { id = "com.android.library", version.ref = "android-gradle" } +android-application = { id = "com.android.application", version.ref = "android-gradle" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e644113 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e2847c8 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..1aa94a4 --- /dev/null +++ b/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..cefb289 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,25 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "chart-realtime-root" +include(":chart-realtime") +include(":app")