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 <noreply@anthropic.com>
This commit is contained in:
commit
b9ee5ddfa3
38 changed files with 2005 additions and 0 deletions
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Gradle
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
**/build/
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# IntelliJ / Android Studio
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
*.iws
|
||||||
|
*.ipr
|
||||||
|
|
||||||
|
# Kotlin
|
||||||
|
.kotlin/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
106
.paul/PROJECT.md
Normal file
106
.paul/PROJECT.md
Normal file
|
|
@ -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*
|
||||||
124
.paul/ROADMAP.md
Normal file
124
.paul/ROADMAP.md
Normal file
|
|
@ -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*
|
||||||
61
.paul/STATE.md
Normal file
61
.paul/STATE.md
Normal file
|
|
@ -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*
|
||||||
25
.paul/paul.json
Normal file
25
.paul/paul.json
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
53
.paul/phases/v0.2.0-plan.md
Normal file
53
.paul/phases/v0.2.0-plan.md
Normal file
|
|
@ -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.
|
||||||
40
app/build.gradle.kts
Normal file
40
app/build.gradle.kts
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
15
app/src/main/AndroidManifest.xml
Normal file
15
app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application
|
||||||
|
android:label="ChartDemo"
|
||||||
|
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
73
app/src/main/kotlin/dev/dtrentin/chart/demo/MainActivity.kt
Normal file
73
app/src/main/kotlin/dev/dtrentin/chart/demo/MainActivity.kt
Normal file
|
|
@ -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())
|
||||||
|
}
|
||||||
8
build.gradle.kts
Normal file
8
build.gradle.kts
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
80
chart-realtime/README.md
Normal file
80
chart-realtime/README.md
Normal file
|
|
@ -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<Float> (auto-timestamp)
|
||||||
|
state.collectFrom("accel_x", accelerometerFlow, viewModelScope)
|
||||||
|
|
||||||
|
// From Flow<Pair<Long, Float>> (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+
|
||||||
58
chart-realtime/build.gradle.kts
Normal file
58
chart-realtime/build.gradle.kts
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
2
chart-realtime/src/androidMain/AndroidManifest.xml
Normal file
2
chart-realtime/src/androidMain/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest />
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
package dev.dtrentin.chart
|
||||||
|
|
||||||
|
internal actual fun currentTimeMs(): Long = System.currentTimeMillis()
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
package dev.dtrentin.chart
|
||||||
|
|
||||||
|
internal expect fun currentTimeMs(): Long
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<Map<String, SignalEntry>>(emptyMap())
|
||||||
|
internal val signals: Map<String, SignalEntry> 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<Pair<Long, Float>>, 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<Float>, scope: CoroutineScope): Job =
|
||||||
|
scope.launch { flow.collect { v -> push(name, currentTimeMs(), v) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class SignalEntry(
|
||||||
|
val config: SignalConfig,
|
||||||
|
val buffer: TieredBuffer,
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
package dev.dtrentin.chart.model
|
||||||
|
|
||||||
|
enum class LodMode { MEAN, MIN_MAX, LTTB }
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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)" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<Float, Float> =
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
10
gradle.properties
Normal file
10
gradle.properties
Normal file
|
|
@ -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
|
||||||
33
gradle/libs.versions.toml
Normal file
33
gradle/libs.versions.toml
Normal file
|
|
@ -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" }
|
||||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -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
|
||||||
249
gradlew
vendored
Executable file
249
gradlew
vendored
Executable file
|
|
@ -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" "$@"
|
||||||
25
settings.gradle.kts
Normal file
25
settings.gradle.kts
Normal file
|
|
@ -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")
|
||||||
Loading…
Add table
Reference in a new issue