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