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:
dtrentin 2026-05-21 01:48:51 +02:00
commit b9ee5ddfa3
38 changed files with 2005 additions and 0 deletions

18
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
}
}

View 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
View 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)
}

View 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>

View 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
View 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
View 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+

View 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
}

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />

View file

@ -0,0 +1,3 @@
package dev.dtrentin.chart
internal actual fun currentTimeMs(): Long = System.currentTimeMillis()

View file

@ -0,0 +1,3 @@
package dev.dtrentin.chart
internal expect fun currentTimeMs(): Long

View file

@ -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,
)
}
}
}

View file

@ -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,
)

View file

@ -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)
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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,
)

View file

@ -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,
)
}
}

View file

@ -0,0 +1,3 @@
package dev.dtrentin.chart.model
enum class LodMode { MEAN, MIN_MAX, LTTB }

View file

@ -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,
)

View file

@ -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()
}

View file

@ -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)" } }
}
}

View file

@ -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
}
}
}

View file

@ -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
}

View file

@ -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))
}
}
}

View file

@ -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)
}
}

View file

@ -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
View 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
View 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

Binary file not shown.

View 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
View 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
View 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")