feat(chart): v0.5.0 architecture + interaction

Ships v0.5.0 via kmp-manager 6-phase flow. 13 plan tasks (D1, T1-T10, D3, D4),
14 agent calls, 6 P-groups. 107 → 207 tests on iosSimulatorArm64.

Toolchain (D1):
- Kotlin 2.1.0 → 2.3.21
- Compose-Multiplatform 1.8.0 → 1.11.0
- Android Gradle Plugin 8.7.3 → 9.2.0
- Gradle 8.11.1 → 9.5.1
- coroutines 1.9.0 → 1.11.0, kotlin-test → 2.3.21
- Drop kotlinx-datetime; use stdlib kotlin.time.Clock
- Drop iosX64 target (Compose-MP 1.11.0 has no ios_x64 variant)

Architecture (T1-T6, T10):
- TieredBuffer.snapshotWindow: bisect-based windowed snapshot
- New lod/ package: LodStrategy interface + MinMax/Lttb/MinMaxLttb impls
  (MinMaxLttb SOTA per arXiv 2305.00332, 1.80× faster than pure LTTB)
- New render/SignalRenderer: public interface + LineSignalRenderer object
- New render/AxisFormatter: 4 default impls (Time, Decimal, DateTime, Unit)
- HARD BREAK: deleted LodMode, LodDecimator, ChartConfig.targetFps
- ChartConfig split: DataConfig + AxisConfig + RenderConfig + FrameRate sealed
- @Immutable/@Stable on all public types (0 unstable)
- RealtimeChartState.clear() API

Interaction layer (T7):
- New interaction/ package
- ChartInteractionState + rememberChartInteractionState()
- ViewportMode sealed: Following / Frozen / History(anchorMs)
- Pinch zoom + drag pan + tap crosshair gestures
- Swipe-to-edge resumes Following
- InverseProjection: pixel → ms + bisect nearest-sample

Perf finishing (T8, T9, D3):
- LineSignalRenderer Stroke cache, AxisRenderer TextStyle cache
- resolveYRange Pair<Float,Float> → FloatArray out-param
- RealtimeChartState.signalsArray cached (invalidated on add/remove only)
- LTTB upper-bound aligned to half-open [start, start+windowMs) semantic

Correctness (D4):
- NumberFormat.formatFixed Long overflow guard @ |v|≥1e19

ABI baseline regenerated:
- chart-realtime.api: 161 → 428 LOC
- chart-realtime.klib.api: 211 → 501 LOC

Modules touched: chart-realtime (lib), app (consumer), gradle (toolchain),
.paul (state).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trentin Davide 2026-05-22 00:05:43 +02:00
parent 0e3b79e2da
commit af6814e2b8
55 changed files with 3738 additions and 488 deletions

View file

@ -12,7 +12,7 @@ KMP real-time chart library. Six phases v0.1.0 MVP shipped; v0.2.0 polish shippe
| v0.2.0 Polish | 0.5.0-SNAPSHOT | ✓ Shipped | 2026-05-21 | | v0.2.0 Polish | 0.5.0-SNAPSHOT | ✓ Shipped | 2026-05-21 |
| v0.3.0 iOS Build Pipeline | 0.5.0-SNAPSHOT | ✓ Shipped | 2026-05-21 | | v0.3.0 iOS Build Pipeline | 0.5.0-SNAPSHOT | ✓ Shipped | 2026-05-21 |
| v0.4.0 Portfolio Hardening | 0.5.0-SNAPSHOT | ✓ Shipped | 2026-05-21 | | v0.4.0 Portfolio Hardening | 0.5.0-SNAPSHOT | ✓ Shipped | 2026-05-21 |
| v0.5.0 Architecture + Interaction | - | Planned | - | | v0.5.0 Architecture + Interaction | 0.5.0-SNAPSHOT | ✓ Shipped | 2026-05-21 |
| v1.0.0 Public Release | - | Planned | - | | v1.0.0 Public Release | - | Planned | - |
--- ---
@ -66,7 +66,7 @@ Plan: .paul/phases/v0.3.0-ios-build-plan.md
### Delivered ### Delivered
- 3 iOS targets compile green (iosX64, iosArm64, iosSimulatorArm64) - 3 iOS targets compile green (iosX64, iosArm64, iosSimulatorArm64) — iosX64 dropped in v0.5.0 D1 (Compose-MP 1.11.0 no ios_x64 variant)
- `applyDefaultHierarchyTemplate()` wires iosMain/iosTest across targets - `applyDefaultHierarchyTemplate()` wires iosMain/iosTest across targets
- `linkDebugFrameworkIosSimulatorArm64` green - `linkDebugFrameworkIosSimulatorArm64` green
- `iosSimulatorArm64Test` green — 33 tests, 0 failures - `iosSimulatorArm64Test` green — 33 tests, 0 failures
@ -128,23 +128,66 @@ Completed: 2026-05-21
--- ---
## v0.5.0 — Architecture + Interaction (PLANNED) ## v0.5.0 — Architecture + Interaction (SHIPPED)
Plan: .paul/phases/v0.5.0-architecture-plan.md Plan: .paul/phases/v0.5.0-architecture-plan.md (v2 deltas)
Effort: 5-7 giorni full-time, ~12 agent calls Executed via: kmp-manager 6-phase flow, 6 P-groups, ~14 agent calls
Prereq: v0.4.0 shipped Completed: 2026-05-21
### Highlights ### Delivered
- Interaction layer S0 (pinch zoom + drag pan + crosshair tap) — fatale senza
- LodStrategy/SignalRenderer/AxisFormatter interfaces (apri estensibilità) **Toolchain (D1):**
- MinMaxLTTB SOTA algorithm (10x faster than LTTB puro, arXiv 2305.00332) - Kotlin 2.1.0 → 2.3.21
- Bisect tier snapshot per timestamp window (30x perf win) - Compose-Multiplatform 1.8.0 → 1.11.0
- ChartConfig split DataConfig/AxisConfig/RenderConfig - Android Gradle Plugin 8.7.3 → 9.2.0
- FrameRate sealed (replace `targetFps: Int?` nullable-as-sentinel) - Gradle 8.11.1 → 9.5.1
- Compose @Stable/@Immutable annotations - coroutines 1.9.0 → 1.11.0, kotlin-test → 2.3.21
- Cache Stroke + TextStyle + TextLayoutResult (zero-alloc render reale) - Dropped `kotlinx-datetime`; swapped to stdlib `kotlin.time.Clock` (Kotlin 2.3 stable)
- Replace Pair<Float,Float> return + Map iter alloc - Dropped `iosX64` target (Compose-MP 1.11.0 incompat — Intel Mac sims deprecated)
- RealtimeChartState.clear() API
**Architecture (T1-T6, T10):**
- T1: Bisect tier snapshot (`TieredBuffer.snapshotWindow`) — 26 new tests
- T2: `LodStrategy` interface + `MinMaxLodStrategy` + `LttbLodStrategy` + `MinMaxLttbLodStrategy` (SOTA, 1.80× faster than pure LTTB on iOS sim; arXiv 2305.00332 ratio=4)
- T3: `SignalRenderer` interface (public) + `LineSignalRenderer` default impl
- T4: `AxisFormatter` interface + `TimeAxisFormatter` + `DecimalAxisFormatter` + `DateTimeAxisFormatter` + `UnitAxisFormatter`
- T5: **HARD BREAK** — deleted `LodMode` enum + `LodDecimator` + `ChartConfig.targetFps`. Split `ChartConfig``DataConfig` + `AxisConfig` + `RenderConfig` + `FrameRate` sealed
- T6: `@Immutable`/`@Stable` annotations — 0 unstable public types
- T10: `RealtimeChartState.clear()` API
**Interaction layer (T7):**
- `ChartInteractionState` + `rememberChartInteractionState()` hoistable state
- `ViewportMode` sealed: `Following` / `Frozen` / `History(anchorMs)`
- `CrosshairState` + `SignalValueAt` (List, not Map — zero boxing)
- `InteractionConfig` (zoomEnabled/panEnabled/tapCrosshairEnabled + clamps)
- Pinch zoom + drag pan + tap crosshair + swipe-to-edge-resumes-Following
- `InverseProjection`: pixel→ms + bisect-based nearest-sample
**Perf finishing (T8, T9, D3):**
- T8: `LineSignalRenderer.Stroke` cache + `AxisRenderer.TextStyle` cache (16-entry cap, single-thread UI)
- T9: `Pair<Float,Float>` removed (FloatArray out-param) + `signalsArray` cached on `RealtimeChartState` (invalidated by addSignal/removeSignal only)
- D3: LTTB upper-bound aligned to half-open `[start, start+windowMs)` snapshot semantic
**Correctness (D4):**
- D4: `NumberFormat.formatFixed` Long overflow guard @ |v|≥1e19 → routes to scientific
**Test gap fill:**
- 107 → **207 tests** (+100)
- New suites: lod/ (24 tests), interaction/ (29 tests), model/ChartConfigTest (8), render/AxisFormatterTest (10), render/AxisRendererTest (3), render/LineSignalRendererTest (2)
**ABI:**
- Baseline regenerated; apiCheck GREEN
- `chart-realtime.api`: 161 → 428 LOC
- `chart-realtime.klib.api`: 211 → 501 LOC
**Deviations from plan:**
- `iosX64` target dropped (Compose-MP 1.11.0 dropped Intel iOS sim variant)
- `MinMaxLttb` perf 1.80× (paper claims 10× — gap = scalar Kotlin/Native vs Rust+SIMD; ≥1× acceptance met)
- `signalsArray` cache NOT invalidated by `clear()` (entries preserved per T10 contract)
**Highlights vs plan goals:**
- Interaction layer S0 shipped (was "fatale senza")
- HARD BREAK on T2/T5 per intake decision (no deprecated bridges)
- All v0.4.0 deferred items (D1/D3/D4) absorbed
--- ---

View file

@ -9,24 +9,25 @@ See: .paul/PROJECT.md (updated 2026-05-21)
## Current Position ## Current Position
Milestone: v0.5.0-architecture (NEXT) Milestone: v0.5.0-architecture (SHIPPED ✓)
Phase: 0 of N — Planned, not started Phase: All 6 P-groups complete. ABI regenerated. Unify green.
Plan: .paul/phases/v0.5.0-architecture-plan.md Plan: .paul/phases/v0.5.0-architecture-plan.md (v2 deltas applied 2026-05-21)
Status: Awaiting kickoff Status: v0.5.0 closed. Next milestone v1.0.0 public release.
Last completed: v0.4.0 portfolio hardening (2026-05-21) Last completed: v0.5.0 architecture + interaction (2026-05-21)
Last activity: 2026-05-21 — v0.4.0 closed via kmp-manager group-by-group flow. 8 P-groups executed. 15 plan tasks done. 107 tests pass (was 33). ABI lockdown active. Compose snapshot thread-safe. Last activity: 2026-05-21 — v0.5.0 SHIPPED via kmp-manager 6-phase flow. 14 agent calls. 13 plan tasks done (D1, T1-T10, D3, D4). 107 → 207 tests pass on iosSimulatorArm64 (+100). ABI baseline regen: 161/211 → 428/501 LOC. HARD BREAK on LodMode/LodDecimator/targetFps. Interaction layer (pinch/pan/crosshair) shipped. MinMaxLTTB SOTA 1.80× faster than pure LTTB. All toolchain bumps (Kotlin 2.3.21, Compose-MP 1.11.0, AGP 9.2.0, Gradle 9.5.1). kotlinx-datetime dropped (kotlin.time.Clock). iosX64 dropped (Compose-MP 1.11.0 incompat).
Progress: Progress:
- v0.1.0 milestone: [██████████] 100% SHIPPED - v0.1.0 milestone: [██████████] 100% SHIPPED
- v0.2.0 milestone: [██████████] 100% SHIPPED - v0.2.0 milestone: [██████████] 100% SHIPPED
- v0.3.0 milestone: [██████████] 100% SHIPPED - v0.3.0 milestone: [██████████] 100% SHIPPED
- v0.4.0 milestone: [██████████] 100% SHIPPED - v0.4.0 milestone: [██████████] 100% SHIPPED
- v0.5.0 milestone: [██████████] 100% SHIPPED
## Loop Position ## Loop Position
``` ```
PLAN ──▶ APPLY ──▶ UNIFY PLAN ──▶ APPLY ──▶ UNIFY
✓ ✓ ✓ [v0.4.0 portfolio hardening SHIPPED — v0.5.0-SNAPSHOT] ✓ ✓ ✓ [v0.5.0 architecture + interaction SHIPPED — v0.5.0-SNAPSHOT]
``` ```
## Accumulated Context ## Accumulated Context
@ -43,7 +44,8 @@ PLAN ──▶ APPLY ──▶ UNIFY
| LodDecimator as class (pre-alloc scratch) | v0.2.0 | Zero GC at render time | | LodDecimator as class (pre-alloc scratch) | v0.2.0 | Zero GC at render time |
| Sensor cap 200Hz (5_000μs) | v0.2.0 | Thermal/battery optimization | | Sensor cap 200Hz (5_000μs) | v0.2.0 | Thermal/battery optimization |
| Package dev.dtrentin | v0.2.0 | Personal project, not company | | Package dev.dtrentin | v0.2.0 | Personal project, not company |
| iOS targets shipped | v0.3.0 | iosX64/iosArm64/iosSimulatorArm64 + Platform.ios.kt | | iOS targets shipped | v0.3.0 | iosX64/iosArm64/iosSimulatorArm64 + Platform.ios.kt (iosX64 dropped in v0.5.0 D1) |
| Drop iosX64 target | v0.5.0 D1 | Compose-MP 1.11.0 no ios_x64 variant; Apple deprecated Intel Mac sims Xcode 15+; iosArm64 + iosSimulatorArm64 cover modern devs |
| iOS build pipeline green | v0.3.0 iOS | 33 tests pass on iOS sim, XCFramework "ChartRealtime" produced | | iOS build pipeline green | v0.3.0 iOS | 33 tests pass on iOS sim, XCFramework "ChartRealtime" produced |
| timeIntervalSinceReferenceDate + epoch offset | v0.3.0 iOS | K/Native binding doesn't expose timeIntervalSince1970; use 978_307_200s constant | | timeIntervalSinceReferenceDate + epoch offset | v0.3.0 iOS | K/Native binding doesn't expose timeIntervalSince1970; use 978_307_200s constant |
| applyDefaultHierarchyTemplate() explicit | v0.3.0 iOS | iosMain/iosTest auto-wired across 3 iOS targets | | applyDefaultHierarchyTemplate() explicit | v0.3.0 iOS | iosMain/iosTest auto-wired across 3 iOS targets |

View file

@ -143,17 +143,94 @@ v0.4.0 shipped (hygiene + correctness).
| P4 | T8, T9 (perf finishing) | | P4 | T8, T9 (perf finishing) |
## Estimated agent calls ## Estimated agent calls
~12 agent calls. ~5-7 giorni full-time. ~14 agent calls. ~6-8 giorni full-time (with v2 deltas).
## Risks ## Risks
- T2 (MinMaxLTTB): break LodMode enum API → migration required by consumers - T2 (MinMaxLTTB): break LodMode enum API → migration required by consumers
- T7 (interaction): biggest scope, multi-modal touch testing manual - T7 (interaction): biggest scope, multi-modal touch testing manual
- T5 (config split): breaking API change, mitigare con typealias + deprecated factory - T5 (config split): breaking API change → HARD BREAK per intake decision
- D1 (toolchain bump): Kotlin 2.1→2.3 + Compose 1.8→1.11 behavior surprises
- BCV 0.18.1 (Jul 2022 release age): agent verifies latest at apply, fallback 0.16.3
- AGP 9.2.0 may need Gradle wrapper bump
## Verification commands ## Verification commands
```bash ```bash
./gradlew :chart-realtime:apiDump
./gradlew :chart-realtime:apiCheck ./gradlew :chart-realtime:apiCheck
./gradlew :chart-realtime:iosSimulatorArm64Test ./gradlew :chart-realtime:iosSimulatorArm64Test
./gradlew :app:installDebug # manual interaction test ./gradlew :app:installDebug # manual interaction test
./gradlew :chart-realtime:assembleRelease ./gradlew :chart-realtime:assembleRelease
./gradlew :chart-realtime:publishToMavenLocal --dry-run
``` ```
---
## v2 deltas (intake-confirmed 2026-05-21)
Source: kmp-manager intake. Applied directly by manager (mechanical scope additions, no architectural change).
### Decisions locked
- **Breaking API**: HARD BREAK on T2 (`LodMode` removed) + T5 (old `ChartConfig` ctor + `targetFps` removed). No `@Deprecated` bridges. ABI baseline regen.
- **Versioning**: stay `0.5.0-SNAPSHOT` at close. No tag. No publish.
- **Datetime**: drop `kotlinx-datetime` entirely. Use `kotlin.time.Clock.System.now().toEpochMilliseconds()` (stable since Kotlin 2.3.0, no `@OptIn`).
- **Toolchain**: full alignment — Kotlin 2.3.21, Compose-MP 1.11.0, AGP 9.2.0, coroutines + kotlin-test aligned, BCV verify-latest.
### New tasks
#### D1 — Toolchain bump (P0, blocks all)
- Files:
- `gradle/libs.versions.toml`
- `gradle/wrapper/gradle-wrapper.properties` (if Gradle bump needed for AGP 9.2.0)
- `chart-realtime/build.gradle.kts` (drop `kotlinx-datetime` dep)
- `chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChartState.kt:16,90` (swap `kotlinx.datetime.Clock``kotlin.time.Clock`)
- Goal: Kotlin 2.1.0→2.3.21, Compose-MP 1.8.0→1.11.0, AGP 8.7.3→9.2.0, coroutines 1.9.0→latest, kotlin-test→2.3.21, BCV→verify-latest (fallback 0.16.3). Drop kotlinx-datetime. Single call site swap.
- Agent: gradle-expert
- Verify:
- `./gradlew :chart-realtime:compileKotlinAndroid` green
- `./gradlew :chart-realtime:iosSimulatorArm64Test` 107 tests pass (regression gate)
- `./gradlew :chart-realtime:apiCheck` green (no ABI delta from toolchain alone)
- `./gradlew :chart-realtime:assembleRelease` green
#### D3 — LTTB upper-bound semantic fix (P5)
- Files: `chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/buffer/LodDecimator.kt` (or post-T2 `lod/LttbLod.kt`)
- Goal: align LTTB upper-bound with snapshot half-open `[lo, hi)` semantic. Currently inclusive → off-by-one at window boundary.
- Agent: data-impl
- Verify: existing 33 LodDecimator tests pass + 1 new boundary test
#### D4 — NumberFormat Long overflow guard (P1)
- Files: `chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/render/NumberFormat.kt`
- Goal: `formatFixed` Long path overflows @ |v|≥1e19. Add guard → route to `formatScientific` before overflow.
- Agent: data-impl
- Verify: new test `formatFixed_handlesLongOverflow` + existing 38 NumberFormat tests pass
### Task semantic changes
#### T2 (was: deprecated bridges) → HARD BREAK
- Remove `LodMode` enum entirely (was: `ChartConfig.lodMode: LodMode``lodStrategy: LodStrategy`).
- No `LodMode.MIN_MAX_LTTB` shorthand. Pure interface.
- ABI baseline regen: `./gradlew :chart-realtime:apiDump` + commit.
#### T5 (was: deprecated old ctor) → HARD BREAK
- Remove `ChartConfig` original ctor entirely. Replace with new ctor taking `DataConfig + AxisConfig + RenderConfig`.
- Remove `targetFps: Int?` field. Replaced by `RenderConfig.frameRate: FrameRate`.
- No `@Deprecated` bridges.
- ABI baseline regen.
#### T6 — verify SignalConfig stability covered
- Add `@Immutable` to `SignalConfig` (closes deferred v0.3.0 issue: Compose compiler unstable warning).
### Updated P-groups
| Group | Tasks |
|-------|-------|
| P0 | D1 (toolchain, blocks all) |
| P1 | T1, T6, T10, D4 |
| P2 | T2, T3, T4 |
| P3 | T5 |
| P4 | T7 |
| P5 | T8, T9, D3 |
| P6 | ABI regen + apiCheck + unify |
### Acceptance additions
- A10: Kotlin 2.3.21 + Compose-MP 1.11.0 + AGP 9.2.0 green on all targets; 107 tests pass on iosSimulatorArm64.
- A11: ABI baseline regenerated post-T2/T5; `chart-realtime.api` + `chart-realtime.klib.api` committed.
- A12: `LodMode` symbol absent, `targetFps` symbol absent, `kotlinx-datetime` dep absent. No `@Deprecated` bridges in commonMain public API.

View file

@ -21,13 +21,17 @@ android {
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
} }
kotlinOptions { jvmTarget = "17" }
buildFeatures { compose = true } buildFeatures { compose = true }
sourceSets["main"].kotlin.srcDirs("src/main/kotlin") sourceSets["main"].kotlin.srcDirs("src/main/kotlin")
} }
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
dependencies { dependencies {
implementation(project(":chart-realtime")) implementation(project(":chart-realtime"))

View file

@ -14,6 +14,7 @@ import androidx.compose.ui.graphics.Color
import dev.dtrentin.chart.RealtimeChart import dev.dtrentin.chart.RealtimeChart
import dev.dtrentin.chart.RealtimeChartState import dev.dtrentin.chart.RealtimeChartState
import dev.dtrentin.chart.model.ChartConfig import dev.dtrentin.chart.model.ChartConfig
import dev.dtrentin.chart.model.DataConfig
import dev.dtrentin.chart.model.SignalConfig import dev.dtrentin.chart.model.SignalConfig
import dev.dtrentin.chart.model.YRange import dev.dtrentin.chart.model.YRange
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -40,8 +41,10 @@ fun DemoScreen() {
val state = remember { val state = remember {
RealtimeChartState( RealtimeChartState(
config = ChartConfig( config = ChartConfig(
xWindowSeconds = 10f, data = DataConfig(
yRange = YRange.Auto(), xWindowSeconds = 10f,
yRange = YRange.Auto(),
),
), ),
).apply { ).apply {
addSignal("sin", SignalConfig(color = Color(0xFF6750A4))) addSignal("sin", SignalConfig(color = Color(0xFF6750A4)))

View file

@ -1,5 +1,5 @@
public final class dev/dtrentin/chart/RealtimeChartKt { public final class dev/dtrentin/chart/RealtimeChartKt {
public static final fun RealtimeChart (Ldev/dtrentin/chart/RealtimeChartState;Landroidx/compose/ui/Modifier;FLdev/dtrentin/chart/model/ChartTheme;Landroidx/compose/runtime/Composer;II)V public static final fun RealtimeChart (Ldev/dtrentin/chart/RealtimeChartState;Landroidx/compose/ui/Modifier;FLdev/dtrentin/chart/model/ChartTheme;Ldev/dtrentin/chart/interaction/ChartInteractionState;Landroidx/compose/runtime/Composer;II)V
} }
public final class dev/dtrentin/chart/RealtimeChartState { public final class dev/dtrentin/chart/RealtimeChartState {
@ -8,6 +8,7 @@ public final class dev/dtrentin/chart/RealtimeChartState {
public fun <init> (Ldev/dtrentin/chart/model/ChartConfig;)V public fun <init> (Ldev/dtrentin/chart/model/ChartConfig;)V
public synthetic fun <init> (Ldev/dtrentin/chart/model/ChartConfig;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun <init> (Ldev/dtrentin/chart/model/ChartConfig;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun addSignal (Ljava/lang/String;Ldev/dtrentin/chart/model/SignalConfig;)V public final fun addSignal (Ljava/lang/String;Ldev/dtrentin/chart/model/SignalConfig;)V
public final fun clear ()V
public final fun collectFromFloat (Ljava/lang/String;Lkotlinx/coroutines/flow/Flow;Lkotlinx/coroutines/CoroutineScope;)Lkotlinx/coroutines/Job; public final fun collectFromFloat (Ljava/lang/String;Lkotlinx/coroutines/flow/Flow;Lkotlinx/coroutines/CoroutineScope;)Lkotlinx/coroutines/Job;
public final fun collectFromTimestamped (Ljava/lang/String;Lkotlinx/coroutines/flow/Flow;Lkotlinx/coroutines/CoroutineScope;)Lkotlinx/coroutines/Job; public final fun collectFromTimestamped (Ljava/lang/String;Lkotlinx/coroutines/flow/Flow;Lkotlinx/coroutines/CoroutineScope;)Lkotlinx/coroutines/Job;
public final fun getConfig ()Ldev/dtrentin/chart/model/ChartConfig; public final fun getConfig ()Ldev/dtrentin/chart/model/ChartConfig;
@ -15,6 +16,162 @@ public final class dev/dtrentin/chart/RealtimeChartState {
public final fun removeSignal (Ljava/lang/String;)V public final fun removeSignal (Ljava/lang/String;)V
} }
public final class dev/dtrentin/chart/interaction/ChartInteractionState {
public static final field $stable I
public final fun getConfig ()Ldev/dtrentin/chart/interaction/InteractionConfig;
public final fun getCrosshair ()Ldev/dtrentin/chart/interaction/CrosshairState;
public final fun getMode ()Ldev/dtrentin/chart/interaction/ViewportMode;
public final fun getViewportOffsetMs ()J
public final fun getXWindowSecondsOverride ()F
}
public final class dev/dtrentin/chart/interaction/ChartInteractionStateKt {
public static final fun rememberChartInteractionState (Ldev/dtrentin/chart/interaction/InteractionConfig;Landroidx/compose/runtime/Composer;II)Ldev/dtrentin/chart/interaction/ChartInteractionState;
}
public final class dev/dtrentin/chart/interaction/CrosshairState {
public static final field $stable I
public fun <init> (FJLjava/util/List;)V
public final fun component1 ()F
public final fun component2 ()J
public final fun component3 ()Ljava/util/List;
public final fun copy (FJLjava/util/List;)Ldev/dtrentin/chart/interaction/CrosshairState;
public static synthetic fun copy$default (Ldev/dtrentin/chart/interaction/CrosshairState;FJLjava/util/List;ILjava/lang/Object;)Ldev/dtrentin/chart/interaction/CrosshairState;
public fun equals (Ljava/lang/Object;)Z
public final fun getPixelX ()F
public final fun getSignalValues ()Ljava/util/List;
public final fun getTimestampMs ()J
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}
public final class dev/dtrentin/chart/interaction/InteractionConfig {
public static final field $stable I
public fun <init> ()V
public fun <init> (ZZZFFJ)V
public synthetic fun <init> (ZZZFFJILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Z
public final fun component2 ()Z
public final fun component3 ()Z
public final fun component4 ()F
public final fun component5 ()F
public final fun component6 ()J
public final fun copy (ZZZFFJ)Ldev/dtrentin/chart/interaction/InteractionConfig;
public static synthetic fun copy$default (Ldev/dtrentin/chart/interaction/InteractionConfig;ZZZFFJILjava/lang/Object;)Ldev/dtrentin/chart/interaction/InteractionConfig;
public fun equals (Ljava/lang/Object;)Z
public final fun getMaxXWindowSeconds ()F
public final fun getMinXWindowSeconds ()F
public final fun getPanEnabled ()Z
public final fun getResumeFollowingThresholdMs ()J
public final fun getTapCrosshairEnabled ()Z
public final fun getZoomEnabled ()Z
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}
public final class dev/dtrentin/chart/interaction/SignalValueAt {
public static final field $stable I
public fun <init> (Ljava/lang/String;F)V
public final fun component1 ()Ljava/lang/String;
public final fun component2 ()F
public final fun copy (Ljava/lang/String;F)Ldev/dtrentin/chart/interaction/SignalValueAt;
public static synthetic fun copy$default (Ldev/dtrentin/chart/interaction/SignalValueAt;Ljava/lang/String;FILjava/lang/Object;)Ldev/dtrentin/chart/interaction/SignalValueAt;
public fun equals (Ljava/lang/Object;)Z
public final fun getSignalName ()Ljava/lang/String;
public final fun getValue ()F
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}
public abstract class dev/dtrentin/chart/interaction/ViewportMode {
public static final field $stable I
}
public final class dev/dtrentin/chart/interaction/ViewportMode$Following : dev/dtrentin/chart/interaction/ViewportMode {
public static final field $stable I
public static final field INSTANCE Ldev/dtrentin/chart/interaction/ViewportMode$Following;
public fun equals (Ljava/lang/Object;)Z
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}
public final class dev/dtrentin/chart/interaction/ViewportMode$Frozen : dev/dtrentin/chart/interaction/ViewportMode {
public static final field $stable I
public static final field INSTANCE Ldev/dtrentin/chart/interaction/ViewportMode$Frozen;
public fun equals (Ljava/lang/Object;)Z
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}
public final class dev/dtrentin/chart/interaction/ViewportMode$History : dev/dtrentin/chart/interaction/ViewportMode {
public static final field $stable I
public fun <init> (J)V
public final fun component1 ()J
public final fun copy (J)Ldev/dtrentin/chart/interaction/ViewportMode$History;
public static synthetic fun copy$default (Ldev/dtrentin/chart/interaction/ViewportMode$History;JILjava/lang/Object;)Ldev/dtrentin/chart/interaction/ViewportMode$History;
public fun equals (Ljava/lang/Object;)Z
public final fun getAnchorMs ()J
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}
public abstract interface class dev/dtrentin/chart/lod/LodStrategy {
public abstract fun decimate ([J[FIJJI[F[F)I
}
public final class dev/dtrentin/chart/lod/LttbLodStrategy : dev/dtrentin/chart/lod/LodStrategy {
public static final field $stable I
public fun <init> ()V
public fun <init> (II)V
public synthetic fun <init> (IIILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun decimate ([J[FIJJI[F[F)I
}
public final class dev/dtrentin/chart/lod/MinMaxLodStrategy : dev/dtrentin/chart/lod/LodStrategy {
public static final field $stable I
public fun <init> ()V
public fun <init> (II)V
public synthetic fun <init> (IIILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun decimate ([J[FIJJI[F[F)I
}
public final class dev/dtrentin/chart/lod/MinMaxLttbLodStrategy : dev/dtrentin/chart/lod/LodStrategy {
public static final field $stable I
public static final field Companion Ldev/dtrentin/chart/lod/MinMaxLttbLodStrategy$Companion;
public static final field DEFAULT_RATIO I
public fun <init> ()V
public fun <init> (III)V
public synthetic fun <init> (IIIILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun decimate ([J[FIJJI[F[F)I
}
public final class dev/dtrentin/chart/lod/MinMaxLttbLodStrategy$Companion {
}
public final class dev/dtrentin/chart/model/AxisConfig {
public static final field $stable I
public fun <init> ()V
public fun <init> (Ldev/dtrentin/chart/model/AxisLabelMode;Ldev/dtrentin/chart/model/AxisLabelMode;IZLdev/dtrentin/chart/render/AxisFormatter;Ldev/dtrentin/chart/render/AxisFormatter;)V
public synthetic fun <init> (Ldev/dtrentin/chart/model/AxisLabelMode;Ldev/dtrentin/chart/model/AxisLabelMode;IZLdev/dtrentin/chart/render/AxisFormatter;Ldev/dtrentin/chart/render/AxisFormatter;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Ldev/dtrentin/chart/model/AxisLabelMode;
public final fun component2 ()Ldev/dtrentin/chart/model/AxisLabelMode;
public final fun component3 ()I
public final fun component4 ()Z
public final fun component5 ()Ldev/dtrentin/chart/render/AxisFormatter;
public final fun component6 ()Ldev/dtrentin/chart/render/AxisFormatter;
public final fun copy (Ldev/dtrentin/chart/model/AxisLabelMode;Ldev/dtrentin/chart/model/AxisLabelMode;IZLdev/dtrentin/chart/render/AxisFormatter;Ldev/dtrentin/chart/render/AxisFormatter;)Ldev/dtrentin/chart/model/AxisConfig;
public static synthetic fun copy$default (Ldev/dtrentin/chart/model/AxisConfig;Ldev/dtrentin/chart/model/AxisLabelMode;Ldev/dtrentin/chart/model/AxisLabelMode;IZLdev/dtrentin/chart/render/AxisFormatter;Ldev/dtrentin/chart/render/AxisFormatter;ILjava/lang/Object;)Ldev/dtrentin/chart/model/AxisConfig;
public fun equals (Ljava/lang/Object;)Z
public final fun getShowGrid ()Z
public final fun getXFormatter ()Ldev/dtrentin/chart/render/AxisFormatter;
public final fun getXLabelMode ()Ldev/dtrentin/chart/model/AxisLabelMode;
public final fun getYFormatter ()Ldev/dtrentin/chart/render/AxisFormatter;
public final fun getYLabelDecimals ()I
public final fun getYLabelMode ()Ldev/dtrentin/chart/model/AxisLabelMode;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}
public final class dev/dtrentin/chart/model/AxisLabelMode : java/lang/Enum { public final class dev/dtrentin/chart/model/AxisLabelMode : java/lang/Enum {
public static final field BESIDE Ldev/dtrentin/chart/model/AxisLabelMode; public static final field BESIDE Ldev/dtrentin/chart/model/AxisLabelMode;
public static final field HIDDEN Ldev/dtrentin/chart/model/AxisLabelMode; public static final field HIDDEN Ldev/dtrentin/chart/model/AxisLabelMode;
@ -27,31 +184,19 @@ public final class dev/dtrentin/chart/model/AxisLabelMode : java/lang/Enum {
public final class dev/dtrentin/chart/model/ChartConfig { public final class dev/dtrentin/chart/model/ChartConfig {
public static final field $stable I public static final field $stable I
public fun <init> ()V public fun <init> ()V
public fun <init> (FLdev/dtrentin/chart/model/YRange;Ldev/dtrentin/chart/model/T0;Ljava/lang/Integer;Ldev/dtrentin/chart/model/ChartTheme;ZLdev/dtrentin/chart/model/AxisLabelMode;Ldev/dtrentin/chart/model/AxisLabelMode;ILdev/dtrentin/chart/model/LodMode;)V public fun <init> (Ldev/dtrentin/chart/model/DataConfig;Ldev/dtrentin/chart/model/AxisConfig;Ldev/dtrentin/chart/model/RenderConfig;)V
public synthetic fun <init> (FLdev/dtrentin/chart/model/YRange;Ldev/dtrentin/chart/model/T0;Ljava/lang/Integer;Ldev/dtrentin/chart/model/ChartTheme;ZLdev/dtrentin/chart/model/AxisLabelMode;Ldev/dtrentin/chart/model/AxisLabelMode;ILdev/dtrentin/chart/model/LodMode;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun <init> (Ldev/dtrentin/chart/model/DataConfig;Ldev/dtrentin/chart/model/AxisConfig;Ldev/dtrentin/chart/model/RenderConfig;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()F public final fun component1 ()Ldev/dtrentin/chart/model/DataConfig;
public final fun component10 ()Ldev/dtrentin/chart/model/LodMode; public final fun component2 ()Ldev/dtrentin/chart/model/AxisConfig;
public final fun component2 ()Ldev/dtrentin/chart/model/YRange; public final fun component3 ()Ldev/dtrentin/chart/model/RenderConfig;
public final fun component3 ()Ldev/dtrentin/chart/model/T0; public final fun copy (Ldev/dtrentin/chart/model/DataConfig;Ldev/dtrentin/chart/model/AxisConfig;Ldev/dtrentin/chart/model/RenderConfig;)Ldev/dtrentin/chart/model/ChartConfig;
public final fun component4 ()Ljava/lang/Integer; public static synthetic fun copy$default (Ldev/dtrentin/chart/model/ChartConfig;Ldev/dtrentin/chart/model/DataConfig;Ldev/dtrentin/chart/model/AxisConfig;Ldev/dtrentin/chart/model/RenderConfig;ILjava/lang/Object;)Ldev/dtrentin/chart/model/ChartConfig;
public final fun component5 ()Ldev/dtrentin/chart/model/ChartTheme;
public final fun component6 ()Z
public final fun component7 ()Ldev/dtrentin/chart/model/AxisLabelMode;
public final fun component8 ()Ldev/dtrentin/chart/model/AxisLabelMode;
public final fun component9 ()I
public final fun copy (FLdev/dtrentin/chart/model/YRange;Ldev/dtrentin/chart/model/T0;Ljava/lang/Integer;Ldev/dtrentin/chart/model/ChartTheme;ZLdev/dtrentin/chart/model/AxisLabelMode;Ldev/dtrentin/chart/model/AxisLabelMode;ILdev/dtrentin/chart/model/LodMode;)Ldev/dtrentin/chart/model/ChartConfig;
public static synthetic fun copy$default (Ldev/dtrentin/chart/model/ChartConfig;FLdev/dtrentin/chart/model/YRange;Ldev/dtrentin/chart/model/T0;Ljava/lang/Integer;Ldev/dtrentin/chart/model/ChartTheme;ZLdev/dtrentin/chart/model/AxisLabelMode;Ldev/dtrentin/chart/model/AxisLabelMode;ILdev/dtrentin/chart/model/LodMode;ILjava/lang/Object;)Ldev/dtrentin/chart/model/ChartConfig;
public fun equals (Ljava/lang/Object;)Z public fun equals (Ljava/lang/Object;)Z
public final fun getLodMode ()Ldev/dtrentin/chart/model/LodMode; public final fun getAxis ()Ldev/dtrentin/chart/model/AxisConfig;
public final fun getShowGrid ()Z public final fun getData ()Ldev/dtrentin/chart/model/DataConfig;
public final fun getT0 ()Ldev/dtrentin/chart/model/T0; public final fun getRender ()Ldev/dtrentin/chart/model/RenderConfig;
public final fun getTargetFps ()Ljava/lang/Integer;
public final fun getTheme ()Ldev/dtrentin/chart/model/ChartTheme; public final fun getTheme ()Ldev/dtrentin/chart/model/ChartTheme;
public final fun getXLabelMode ()Ldev/dtrentin/chart/model/AxisLabelMode;
public final fun getXWindowSeconds ()F public final fun getXWindowSeconds ()F
public final fun getYLabelDecimals ()I
public final fun getYLabelMode ()Ldev/dtrentin/chart/model/AxisLabelMode;
public final fun getYRange ()Ldev/dtrentin/chart/model/YRange;
public fun hashCode ()I public fun hashCode ()I
public fun toString ()Ljava/lang/String; public fun toString ()Ljava/lang/String;
} }
@ -81,25 +226,79 @@ public final class dev/dtrentin/chart/model/ChartThemeKt {
public static final fun rememberMaterialChartTheme (Landroidx/compose/runtime/Composer;I)Ldev/dtrentin/chart/model/ChartTheme; public static final fun rememberMaterialChartTheme (Landroidx/compose/runtime/Composer;I)Ldev/dtrentin/chart/model/ChartTheme;
} }
public final class dev/dtrentin/chart/model/LodMode : java/lang/Enum { public final class dev/dtrentin/chart/model/DataConfig {
public static final field LTTB Ldev/dtrentin/chart/model/LodMode; public static final field $stable I
public static final field MIN_MAX Ldev/dtrentin/chart/model/LodMode; public fun <init> ()V
public static fun getEntries ()Lkotlin/enums/EnumEntries; public fun <init> (FLdev/dtrentin/chart/model/YRange;Ldev/dtrentin/chart/model/T0;)V
public static fun valueOf (Ljava/lang/String;)Ldev/dtrentin/chart/model/LodMode; public synthetic fun <init> (FLdev/dtrentin/chart/model/YRange;Ldev/dtrentin/chart/model/T0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public static fun values ()[Ldev/dtrentin/chart/model/LodMode; public final fun component1 ()F
public final fun component2 ()Ldev/dtrentin/chart/model/YRange;
public final fun component3 ()Ldev/dtrentin/chart/model/T0;
public final fun copy (FLdev/dtrentin/chart/model/YRange;Ldev/dtrentin/chart/model/T0;)Ldev/dtrentin/chart/model/DataConfig;
public static synthetic fun copy$default (Ldev/dtrentin/chart/model/DataConfig;FLdev/dtrentin/chart/model/YRange;Ldev/dtrentin/chart/model/T0;ILjava/lang/Object;)Ldev/dtrentin/chart/model/DataConfig;
public fun equals (Ljava/lang/Object;)Z
public final fun getT0 ()Ldev/dtrentin/chart/model/T0;
public final fun getXWindowSeconds ()F
public final fun getYRange ()Ldev/dtrentin/chart/model/YRange;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}
public abstract class dev/dtrentin/chart/model/FrameRate {
public static final field $stable I
}
public final class dev/dtrentin/chart/model/FrameRate$Display : dev/dtrentin/chart/model/FrameRate {
public static final field $stable I
public static final field INSTANCE Ldev/dtrentin/chart/model/FrameRate$Display;
public fun equals (Ljava/lang/Object;)Z
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}
public final class dev/dtrentin/chart/model/FrameRate$Fixed : dev/dtrentin/chart/model/FrameRate {
public static final field $stable I
public fun <init> (I)V
public final fun component1 ()I
public final fun copy (I)Ldev/dtrentin/chart/model/FrameRate$Fixed;
public static synthetic fun copy$default (Ldev/dtrentin/chart/model/FrameRate$Fixed;IILjava/lang/Object;)Ldev/dtrentin/chart/model/FrameRate$Fixed;
public fun equals (Ljava/lang/Object;)Z
public final fun getFps ()I
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}
public final class dev/dtrentin/chart/model/RenderConfig {
public static final field $stable I
public fun <init> ()V
public fun <init> (Ldev/dtrentin/chart/model/ChartTheme;Ldev/dtrentin/chart/model/FrameRate;Ldev/dtrentin/chart/lod/LodStrategy;)V
public synthetic fun <init> (Ldev/dtrentin/chart/model/ChartTheme;Ldev/dtrentin/chart/model/FrameRate;Ldev/dtrentin/chart/lod/LodStrategy;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Ldev/dtrentin/chart/model/ChartTheme;
public final fun component2 ()Ldev/dtrentin/chart/model/FrameRate;
public final fun component3 ()Ldev/dtrentin/chart/lod/LodStrategy;
public final fun copy (Ldev/dtrentin/chart/model/ChartTheme;Ldev/dtrentin/chart/model/FrameRate;Ldev/dtrentin/chart/lod/LodStrategy;)Ldev/dtrentin/chart/model/RenderConfig;
public static synthetic fun copy$default (Ldev/dtrentin/chart/model/RenderConfig;Ldev/dtrentin/chart/model/ChartTheme;Ldev/dtrentin/chart/model/FrameRate;Ldev/dtrentin/chart/lod/LodStrategy;ILjava/lang/Object;)Ldev/dtrentin/chart/model/RenderConfig;
public fun equals (Ljava/lang/Object;)Z
public final fun getFrameRate ()Ldev/dtrentin/chart/model/FrameRate;
public final fun getLodStrategy ()Ldev/dtrentin/chart/lod/LodStrategy;
public final fun getTheme ()Ldev/dtrentin/chart/model/ChartTheme;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
} }
public final class dev/dtrentin/chart/model/SignalConfig { public final class dev/dtrentin/chart/model/SignalConfig {
public static final field $stable I public static final field $stable I
public synthetic fun <init> (JFZILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun <init> (JFZLdev/dtrentin/chart/render/SignalRenderer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public synthetic fun <init> (JFZLkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun <init> (JFZLdev/dtrentin/chart/render/SignalRenderer;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1-0d7_KjU ()J public final fun component1-0d7_KjU ()J
public final fun component2 ()F public final fun component2 ()F
public final fun component3 ()Z public final fun component3 ()Z
public final fun copy-ek8zF_U (JFZ)Ldev/dtrentin/chart/model/SignalConfig; public final fun component4 ()Ldev/dtrentin/chart/render/SignalRenderer;
public static synthetic fun copy-ek8zF_U$default (Ldev/dtrentin/chart/model/SignalConfig;JFZILjava/lang/Object;)Ldev/dtrentin/chart/model/SignalConfig; public final fun copy-Iv8Zu3U (JFZLdev/dtrentin/chart/render/SignalRenderer;)Ldev/dtrentin/chart/model/SignalConfig;
public static synthetic fun copy-Iv8Zu3U$default (Ldev/dtrentin/chart/model/SignalConfig;JFZLdev/dtrentin/chart/render/SignalRenderer;ILjava/lang/Object;)Ldev/dtrentin/chart/model/SignalConfig;
public fun equals (Ljava/lang/Object;)Z public fun equals (Ljava/lang/Object;)Z
public final fun getColor-0d7_KjU ()J public final fun getColor-0d7_KjU ()J
public final fun getRenderer ()Ldev/dtrentin/chart/render/SignalRenderer;
public final fun getStrokeWidth ()F public final fun getStrokeWidth ()F
public final fun getVisible ()Z public final fun getVisible ()Z
public fun hashCode ()I public fun hashCode ()I
@ -159,3 +358,71 @@ public final class dev/dtrentin/chart/model/YRange$Fixed : dev/dtrentin/chart/mo
public fun toString ()Ljava/lang/String; public fun toString ()Ljava/lang/String;
} }
public abstract interface class dev/dtrentin/chart/render/AxisFormatter {
public abstract fun format (D)Ljava/lang/String;
}
public final class dev/dtrentin/chart/render/DateTimeAxisFormatter : dev/dtrentin/chart/render/AxisFormatter {
public static final field $stable I
public fun <init> ()V
public fun <init> (Z)V
public synthetic fun <init> (ZILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Z
public final fun copy (Z)Ldev/dtrentin/chart/render/DateTimeAxisFormatter;
public static synthetic fun copy$default (Ldev/dtrentin/chart/render/DateTimeAxisFormatter;ZILjava/lang/Object;)Ldev/dtrentin/chart/render/DateTimeAxisFormatter;
public fun equals (Ljava/lang/Object;)Z
public fun format (D)Ljava/lang/String;
public final fun getShowSeconds ()Z
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}
public final class dev/dtrentin/chart/render/DecimalAxisFormatter : dev/dtrentin/chart/render/AxisFormatter {
public static final field $stable I
public fun <init> ()V
public fun <init> (I)V
public synthetic fun <init> (IILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()I
public final fun copy (I)Ldev/dtrentin/chart/render/DecimalAxisFormatter;
public static synthetic fun copy$default (Ldev/dtrentin/chart/render/DecimalAxisFormatter;IILjava/lang/Object;)Ldev/dtrentin/chart/render/DecimalAxisFormatter;
public fun equals (Ljava/lang/Object;)Z
public fun format (D)Ljava/lang/String;
public final fun getDecimals ()I
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}
public final class dev/dtrentin/chart/render/LineSignalRenderer : dev/dtrentin/chart/render/SignalRenderer {
public static final field $stable I
public static final field INSTANCE Ldev/dtrentin/chart/render/LineSignalRenderer;
public fun drawSignal-0JaWfxQ (Landroidx/compose/ui/graphics/drawscope/DrawScope;JFZ[F[FILandroidx/compose/ui/graphics/Path;FFFFF)V
}
public abstract interface class dev/dtrentin/chart/render/SignalRenderer {
public abstract fun drawSignal-0JaWfxQ (Landroidx/compose/ui/graphics/drawscope/DrawScope;JFZ[F[FILandroidx/compose/ui/graphics/Path;FFFFF)V
}
public final class dev/dtrentin/chart/render/TimeAxisFormatter : dev/dtrentin/chart/render/AxisFormatter {
public static final field $stable I
public static final field INSTANCE Ldev/dtrentin/chart/render/TimeAxisFormatter;
public fun format (D)Ljava/lang/String;
}
public final class dev/dtrentin/chart/render/UnitAxisFormatter : dev/dtrentin/chart/render/AxisFormatter {
public static final field $stable I
public fun <init> (Ljava/lang/String;ILjava/lang/String;)V
public synthetic fun <init> (Ljava/lang/String;ILjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Ljava/lang/String;
public final fun component2 ()I
public final fun component3 ()Ljava/lang/String;
public final fun copy (Ljava/lang/String;ILjava/lang/String;)Ldev/dtrentin/chart/render/UnitAxisFormatter;
public static synthetic fun copy$default (Ldev/dtrentin/chart/render/UnitAxisFormatter;Ljava/lang/String;ILjava/lang/String;ILjava/lang/Object;)Ldev/dtrentin/chart/render/UnitAxisFormatter;
public fun equals (Ljava/lang/Object;)Z
public fun format (D)Ljava/lang/String;
public final fun getDecimals ()I
public final fun getSeparator ()Ljava/lang/String;
public final fun getUnit ()Ljava/lang/String;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

View file

@ -1,5 +1,5 @@
// Klib ABI Dump // Klib ABI Dump
// Targets: [iosArm64, iosSimulatorArm64, iosX64] // Targets: [iosArm64, iosSimulatorArm64]
// Rendering settings: // Rendering settings:
// - Signature version: 2 // - Signature version: 2
// - Show manifest properties: true // - Show manifest properties: true
@ -18,52 +18,163 @@ final enum class dev.dtrentin.chart.model/AxisLabelMode : kotlin/Enum<dev.dtrent
final fun values(): kotlin/Array<dev.dtrentin.chart.model/AxisLabelMode> // dev.dtrentin.chart.model/AxisLabelMode.values|values#static(){}[0] final fun values(): kotlin/Array<dev.dtrentin.chart.model/AxisLabelMode> // dev.dtrentin.chart.model/AxisLabelMode.values|values#static(){}[0]
} }
final enum class dev.dtrentin.chart.model/LodMode : kotlin/Enum<dev.dtrentin.chart.model/LodMode> { // dev.dtrentin.chart.model/LodMode|null[0] abstract interface dev.dtrentin.chart.lod/LodStrategy { // dev.dtrentin.chart.lod/LodStrategy|null[0]
enum entry LTTB // dev.dtrentin.chart.model/LodMode.LTTB|null[0] abstract fun decimate(kotlin/LongArray, kotlin/FloatArray, kotlin/Int, kotlin/Long, kotlin/Long, kotlin/Int, kotlin/FloatArray, kotlin/FloatArray): kotlin/Int // dev.dtrentin.chart.lod/LodStrategy.decimate|decimate(kotlin.LongArray;kotlin.FloatArray;kotlin.Int;kotlin.Long;kotlin.Long;kotlin.Int;kotlin.FloatArray;kotlin.FloatArray){}[0]
enum entry MIN_MAX // dev.dtrentin.chart.model/LodMode.MIN_MAX|null[0] }
final val entries // dev.dtrentin.chart.model/LodMode.entries|#static{}entries[0] abstract interface dev.dtrentin.chart.render/AxisFormatter { // dev.dtrentin.chart.render/AxisFormatter|null[0]
final fun <get-entries>(): kotlin.enums/EnumEntries<dev.dtrentin.chart.model/LodMode> // dev.dtrentin.chart.model/LodMode.entries.<get-entries>|<get-entries>#static(){}[0] abstract fun format(kotlin/Double): kotlin/String // dev.dtrentin.chart.render/AxisFormatter.format|format(kotlin.Double){}[0]
}
final fun valueOf(kotlin/String): dev.dtrentin.chart.model/LodMode // dev.dtrentin.chart.model/LodMode.valueOf|valueOf#static(kotlin.String){}[0] abstract interface dev.dtrentin.chart.render/SignalRenderer { // dev.dtrentin.chart.render/SignalRenderer|null[0]
final fun values(): kotlin/Array<dev.dtrentin.chart.model/LodMode> // dev.dtrentin.chart.model/LodMode.values|values#static(){}[0] abstract fun (androidx.compose.ui.graphics.drawscope/DrawScope).drawSignal(androidx.compose.ui.graphics/Color, kotlin/Float, kotlin/Boolean, kotlin/FloatArray, kotlin/FloatArray, kotlin/Int, androidx.compose.ui.graphics/Path, kotlin/Float, kotlin/Float, kotlin/Float, kotlin/Float, kotlin/Float) // dev.dtrentin.chart.render/SignalRenderer.drawSignal|drawSignal@androidx.compose.ui.graphics.drawscope.DrawScope(androidx.compose.ui.graphics.Color;kotlin.Float;kotlin.Boolean;kotlin.FloatArray;kotlin.FloatArray;kotlin.Int;androidx.compose.ui.graphics.Path;kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float){}[0]
}
final class dev.dtrentin.chart.interaction/ChartInteractionState { // dev.dtrentin.chart.interaction/ChartInteractionState|null[0]
final val config // dev.dtrentin.chart.interaction/ChartInteractionState.config|{}config[0]
final fun <get-config>(): dev.dtrentin.chart.interaction/InteractionConfig // dev.dtrentin.chart.interaction/ChartInteractionState.config.<get-config>|<get-config>(){}[0]
final val crosshair // dev.dtrentin.chart.interaction/ChartInteractionState.crosshair|{}crosshair[0]
final fun <get-crosshair>(): dev.dtrentin.chart.interaction/CrosshairState? // dev.dtrentin.chart.interaction/ChartInteractionState.crosshair.<get-crosshair>|<get-crosshair>(){}[0]
final val mode // dev.dtrentin.chart.interaction/ChartInteractionState.mode|{}mode[0]
final fun <get-mode>(): dev.dtrentin.chart.interaction/ViewportMode // dev.dtrentin.chart.interaction/ChartInteractionState.mode.<get-mode>|<get-mode>(){}[0]
final val viewportOffsetMs // dev.dtrentin.chart.interaction/ChartInteractionState.viewportOffsetMs|{}viewportOffsetMs[0]
final fun <get-viewportOffsetMs>(): kotlin/Long // dev.dtrentin.chart.interaction/ChartInteractionState.viewportOffsetMs.<get-viewportOffsetMs>|<get-viewportOffsetMs>(){}[0]
final val xWindowSecondsOverride // dev.dtrentin.chart.interaction/ChartInteractionState.xWindowSecondsOverride|{}xWindowSecondsOverride[0]
final fun <get-xWindowSecondsOverride>(): kotlin/Float // dev.dtrentin.chart.interaction/ChartInteractionState.xWindowSecondsOverride.<get-xWindowSecondsOverride>|<get-xWindowSecondsOverride>(){}[0]
}
final class dev.dtrentin.chart.interaction/CrosshairState { // dev.dtrentin.chart.interaction/CrosshairState|null[0]
constructor <init>(kotlin/Float, kotlin/Long, kotlin.collections/List<dev.dtrentin.chart.interaction/SignalValueAt>) // dev.dtrentin.chart.interaction/CrosshairState.<init>|<init>(kotlin.Float;kotlin.Long;kotlin.collections.List<dev.dtrentin.chart.interaction.SignalValueAt>){}[0]
final val pixelX // dev.dtrentin.chart.interaction/CrosshairState.pixelX|{}pixelX[0]
final fun <get-pixelX>(): kotlin/Float // dev.dtrentin.chart.interaction/CrosshairState.pixelX.<get-pixelX>|<get-pixelX>(){}[0]
final val signalValues // dev.dtrentin.chart.interaction/CrosshairState.signalValues|{}signalValues[0]
final fun <get-signalValues>(): kotlin.collections/List<dev.dtrentin.chart.interaction/SignalValueAt> // dev.dtrentin.chart.interaction/CrosshairState.signalValues.<get-signalValues>|<get-signalValues>(){}[0]
final val timestampMs // dev.dtrentin.chart.interaction/CrosshairState.timestampMs|{}timestampMs[0]
final fun <get-timestampMs>(): kotlin/Long // dev.dtrentin.chart.interaction/CrosshairState.timestampMs.<get-timestampMs>|<get-timestampMs>(){}[0]
final fun component1(): kotlin/Float // dev.dtrentin.chart.interaction/CrosshairState.component1|component1(){}[0]
final fun component2(): kotlin/Long // dev.dtrentin.chart.interaction/CrosshairState.component2|component2(){}[0]
final fun component3(): kotlin.collections/List<dev.dtrentin.chart.interaction/SignalValueAt> // dev.dtrentin.chart.interaction/CrosshairState.component3|component3(){}[0]
final fun copy(kotlin/Float = ..., kotlin/Long = ..., kotlin.collections/List<dev.dtrentin.chart.interaction/SignalValueAt> = ...): dev.dtrentin.chart.interaction/CrosshairState // dev.dtrentin.chart.interaction/CrosshairState.copy|copy(kotlin.Float;kotlin.Long;kotlin.collections.List<dev.dtrentin.chart.interaction.SignalValueAt>){}[0]
final fun equals(kotlin/Any?): kotlin/Boolean // dev.dtrentin.chart.interaction/CrosshairState.equals|equals(kotlin.Any?){}[0]
final fun hashCode(): kotlin/Int // dev.dtrentin.chart.interaction/CrosshairState.hashCode|hashCode(){}[0]
final fun toString(): kotlin/String // dev.dtrentin.chart.interaction/CrosshairState.toString|toString(){}[0]
}
final class dev.dtrentin.chart.interaction/InteractionConfig { // dev.dtrentin.chart.interaction/InteractionConfig|null[0]
constructor <init>(kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Float = ..., kotlin/Float = ..., kotlin/Long = ...) // dev.dtrentin.chart.interaction/InteractionConfig.<init>|<init>(kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Float;kotlin.Float;kotlin.Long){}[0]
final val maxXWindowSeconds // dev.dtrentin.chart.interaction/InteractionConfig.maxXWindowSeconds|{}maxXWindowSeconds[0]
final fun <get-maxXWindowSeconds>(): kotlin/Float // dev.dtrentin.chart.interaction/InteractionConfig.maxXWindowSeconds.<get-maxXWindowSeconds>|<get-maxXWindowSeconds>(){}[0]
final val minXWindowSeconds // dev.dtrentin.chart.interaction/InteractionConfig.minXWindowSeconds|{}minXWindowSeconds[0]
final fun <get-minXWindowSeconds>(): kotlin/Float // dev.dtrentin.chart.interaction/InteractionConfig.minXWindowSeconds.<get-minXWindowSeconds>|<get-minXWindowSeconds>(){}[0]
final val panEnabled // dev.dtrentin.chart.interaction/InteractionConfig.panEnabled|{}panEnabled[0]
final fun <get-panEnabled>(): kotlin/Boolean // dev.dtrentin.chart.interaction/InteractionConfig.panEnabled.<get-panEnabled>|<get-panEnabled>(){}[0]
final val resumeFollowingThresholdMs // dev.dtrentin.chart.interaction/InteractionConfig.resumeFollowingThresholdMs|{}resumeFollowingThresholdMs[0]
final fun <get-resumeFollowingThresholdMs>(): kotlin/Long // dev.dtrentin.chart.interaction/InteractionConfig.resumeFollowingThresholdMs.<get-resumeFollowingThresholdMs>|<get-resumeFollowingThresholdMs>(){}[0]
final val tapCrosshairEnabled // dev.dtrentin.chart.interaction/InteractionConfig.tapCrosshairEnabled|{}tapCrosshairEnabled[0]
final fun <get-tapCrosshairEnabled>(): kotlin/Boolean // dev.dtrentin.chart.interaction/InteractionConfig.tapCrosshairEnabled.<get-tapCrosshairEnabled>|<get-tapCrosshairEnabled>(){}[0]
final val zoomEnabled // dev.dtrentin.chart.interaction/InteractionConfig.zoomEnabled|{}zoomEnabled[0]
final fun <get-zoomEnabled>(): kotlin/Boolean // dev.dtrentin.chart.interaction/InteractionConfig.zoomEnabled.<get-zoomEnabled>|<get-zoomEnabled>(){}[0]
final fun component1(): kotlin/Boolean // dev.dtrentin.chart.interaction/InteractionConfig.component1|component1(){}[0]
final fun component2(): kotlin/Boolean // dev.dtrentin.chart.interaction/InteractionConfig.component2|component2(){}[0]
final fun component3(): kotlin/Boolean // dev.dtrentin.chart.interaction/InteractionConfig.component3|component3(){}[0]
final fun component4(): kotlin/Float // dev.dtrentin.chart.interaction/InteractionConfig.component4|component4(){}[0]
final fun component5(): kotlin/Float // dev.dtrentin.chart.interaction/InteractionConfig.component5|component5(){}[0]
final fun component6(): kotlin/Long // dev.dtrentin.chart.interaction/InteractionConfig.component6|component6(){}[0]
final fun copy(kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Float = ..., kotlin/Float = ..., kotlin/Long = ...): dev.dtrentin.chart.interaction/InteractionConfig // dev.dtrentin.chart.interaction/InteractionConfig.copy|copy(kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Float;kotlin.Float;kotlin.Long){}[0]
final fun equals(kotlin/Any?): kotlin/Boolean // dev.dtrentin.chart.interaction/InteractionConfig.equals|equals(kotlin.Any?){}[0]
final fun hashCode(): kotlin/Int // dev.dtrentin.chart.interaction/InteractionConfig.hashCode|hashCode(){}[0]
final fun toString(): kotlin/String // dev.dtrentin.chart.interaction/InteractionConfig.toString|toString(){}[0]
}
final class dev.dtrentin.chart.interaction/SignalValueAt { // dev.dtrentin.chart.interaction/SignalValueAt|null[0]
constructor <init>(kotlin/String, kotlin/Float) // dev.dtrentin.chart.interaction/SignalValueAt.<init>|<init>(kotlin.String;kotlin.Float){}[0]
final val signalName // dev.dtrentin.chart.interaction/SignalValueAt.signalName|{}signalName[0]
final fun <get-signalName>(): kotlin/String // dev.dtrentin.chart.interaction/SignalValueAt.signalName.<get-signalName>|<get-signalName>(){}[0]
final val value // dev.dtrentin.chart.interaction/SignalValueAt.value|{}value[0]
final fun <get-value>(): kotlin/Float // dev.dtrentin.chart.interaction/SignalValueAt.value.<get-value>|<get-value>(){}[0]
final fun component1(): kotlin/String // dev.dtrentin.chart.interaction/SignalValueAt.component1|component1(){}[0]
final fun component2(): kotlin/Float // dev.dtrentin.chart.interaction/SignalValueAt.component2|component2(){}[0]
final fun copy(kotlin/String = ..., kotlin/Float = ...): dev.dtrentin.chart.interaction/SignalValueAt // dev.dtrentin.chart.interaction/SignalValueAt.copy|copy(kotlin.String;kotlin.Float){}[0]
final fun equals(kotlin/Any?): kotlin/Boolean // dev.dtrentin.chart.interaction/SignalValueAt.equals|equals(kotlin.Any?){}[0]
final fun hashCode(): kotlin/Int // dev.dtrentin.chart.interaction/SignalValueAt.hashCode|hashCode(){}[0]
final fun toString(): kotlin/String // dev.dtrentin.chart.interaction/SignalValueAt.toString|toString(){}[0]
}
final class dev.dtrentin.chart.lod/LttbLodStrategy : dev.dtrentin.chart.lod/LodStrategy { // dev.dtrentin.chart.lod/LttbLodStrategy|null[0]
constructor <init>(kotlin/Int = ..., kotlin/Int = ...) // dev.dtrentin.chart.lod/LttbLodStrategy.<init>|<init>(kotlin.Int;kotlin.Int){}[0]
final fun decimate(kotlin/LongArray, kotlin/FloatArray, kotlin/Int, kotlin/Long, kotlin/Long, kotlin/Int, kotlin/FloatArray, kotlin/FloatArray): kotlin/Int // dev.dtrentin.chart.lod/LttbLodStrategy.decimate|decimate(kotlin.LongArray;kotlin.FloatArray;kotlin.Int;kotlin.Long;kotlin.Long;kotlin.Int;kotlin.FloatArray;kotlin.FloatArray){}[0]
}
final class dev.dtrentin.chart.lod/MinMaxLodStrategy : dev.dtrentin.chart.lod/LodStrategy { // dev.dtrentin.chart.lod/MinMaxLodStrategy|null[0]
constructor <init>(kotlin/Int = ..., kotlin/Int = ...) // dev.dtrentin.chart.lod/MinMaxLodStrategy.<init>|<init>(kotlin.Int;kotlin.Int){}[0]
final fun decimate(kotlin/LongArray, kotlin/FloatArray, kotlin/Int, kotlin/Long, kotlin/Long, kotlin/Int, kotlin/FloatArray, kotlin/FloatArray): kotlin/Int // dev.dtrentin.chart.lod/MinMaxLodStrategy.decimate|decimate(kotlin.LongArray;kotlin.FloatArray;kotlin.Int;kotlin.Long;kotlin.Long;kotlin.Int;kotlin.FloatArray;kotlin.FloatArray){}[0]
}
final class dev.dtrentin.chart.lod/MinMaxLttbLodStrategy : dev.dtrentin.chart.lod/LodStrategy { // dev.dtrentin.chart.lod/MinMaxLttbLodStrategy|null[0]
constructor <init>(kotlin/Int = ..., kotlin/Int = ..., kotlin/Int = ...) // dev.dtrentin.chart.lod/MinMaxLttbLodStrategy.<init>|<init>(kotlin.Int;kotlin.Int;kotlin.Int){}[0]
final fun decimate(kotlin/LongArray, kotlin/FloatArray, kotlin/Int, kotlin/Long, kotlin/Long, kotlin/Int, kotlin/FloatArray, kotlin/FloatArray): kotlin/Int // dev.dtrentin.chart.lod/MinMaxLttbLodStrategy.decimate|decimate(kotlin.LongArray;kotlin.FloatArray;kotlin.Int;kotlin.Long;kotlin.Long;kotlin.Int;kotlin.FloatArray;kotlin.FloatArray){}[0]
final object Companion { // dev.dtrentin.chart.lod/MinMaxLttbLodStrategy.Companion|null[0]
final const val DEFAULT_RATIO // dev.dtrentin.chart.lod/MinMaxLttbLodStrategy.Companion.DEFAULT_RATIO|{}DEFAULT_RATIO[0]
final fun <get-DEFAULT_RATIO>(): kotlin/Int // dev.dtrentin.chart.lod/MinMaxLttbLodStrategy.Companion.DEFAULT_RATIO.<get-DEFAULT_RATIO>|<get-DEFAULT_RATIO>(){}[0]
}
}
final class dev.dtrentin.chart.model/AxisConfig { // dev.dtrentin.chart.model/AxisConfig|null[0]
constructor <init>(dev.dtrentin.chart.model/AxisLabelMode = ..., dev.dtrentin.chart.model/AxisLabelMode = ..., kotlin/Int = ..., kotlin/Boolean = ..., dev.dtrentin.chart.render/AxisFormatter = ..., dev.dtrentin.chart.render/AxisFormatter = ...) // dev.dtrentin.chart.model/AxisConfig.<init>|<init>(dev.dtrentin.chart.model.AxisLabelMode;dev.dtrentin.chart.model.AxisLabelMode;kotlin.Int;kotlin.Boolean;dev.dtrentin.chart.render.AxisFormatter;dev.dtrentin.chart.render.AxisFormatter){}[0]
final val showGrid // dev.dtrentin.chart.model/AxisConfig.showGrid|{}showGrid[0]
final fun <get-showGrid>(): kotlin/Boolean // dev.dtrentin.chart.model/AxisConfig.showGrid.<get-showGrid>|<get-showGrid>(){}[0]
final val xFormatter // dev.dtrentin.chart.model/AxisConfig.xFormatter|{}xFormatter[0]
final fun <get-xFormatter>(): dev.dtrentin.chart.render/AxisFormatter // dev.dtrentin.chart.model/AxisConfig.xFormatter.<get-xFormatter>|<get-xFormatter>(){}[0]
final val xLabelMode // dev.dtrentin.chart.model/AxisConfig.xLabelMode|{}xLabelMode[0]
final fun <get-xLabelMode>(): dev.dtrentin.chart.model/AxisLabelMode // dev.dtrentin.chart.model/AxisConfig.xLabelMode.<get-xLabelMode>|<get-xLabelMode>(){}[0]
final val yFormatter // dev.dtrentin.chart.model/AxisConfig.yFormatter|{}yFormatter[0]
final fun <get-yFormatter>(): dev.dtrentin.chart.render/AxisFormatter // dev.dtrentin.chart.model/AxisConfig.yFormatter.<get-yFormatter>|<get-yFormatter>(){}[0]
final val yLabelDecimals // dev.dtrentin.chart.model/AxisConfig.yLabelDecimals|{}yLabelDecimals[0]
final fun <get-yLabelDecimals>(): kotlin/Int // dev.dtrentin.chart.model/AxisConfig.yLabelDecimals.<get-yLabelDecimals>|<get-yLabelDecimals>(){}[0]
final val yLabelMode // dev.dtrentin.chart.model/AxisConfig.yLabelMode|{}yLabelMode[0]
final fun <get-yLabelMode>(): dev.dtrentin.chart.model/AxisLabelMode // dev.dtrentin.chart.model/AxisConfig.yLabelMode.<get-yLabelMode>|<get-yLabelMode>(){}[0]
final fun component1(): dev.dtrentin.chart.model/AxisLabelMode // dev.dtrentin.chart.model/AxisConfig.component1|component1(){}[0]
final fun component2(): dev.dtrentin.chart.model/AxisLabelMode // dev.dtrentin.chart.model/AxisConfig.component2|component2(){}[0]
final fun component3(): kotlin/Int // dev.dtrentin.chart.model/AxisConfig.component3|component3(){}[0]
final fun component4(): kotlin/Boolean // dev.dtrentin.chart.model/AxisConfig.component4|component4(){}[0]
final fun component5(): dev.dtrentin.chart.render/AxisFormatter // dev.dtrentin.chart.model/AxisConfig.component5|component5(){}[0]
final fun component6(): dev.dtrentin.chart.render/AxisFormatter // dev.dtrentin.chart.model/AxisConfig.component6|component6(){}[0]
final fun copy(dev.dtrentin.chart.model/AxisLabelMode = ..., dev.dtrentin.chart.model/AxisLabelMode = ..., kotlin/Int = ..., kotlin/Boolean = ..., dev.dtrentin.chart.render/AxisFormatter = ..., dev.dtrentin.chart.render/AxisFormatter = ...): dev.dtrentin.chart.model/AxisConfig // dev.dtrentin.chart.model/AxisConfig.copy|copy(dev.dtrentin.chart.model.AxisLabelMode;dev.dtrentin.chart.model.AxisLabelMode;kotlin.Int;kotlin.Boolean;dev.dtrentin.chart.render.AxisFormatter;dev.dtrentin.chart.render.AxisFormatter){}[0]
final fun equals(kotlin/Any?): kotlin/Boolean // dev.dtrentin.chart.model/AxisConfig.equals|equals(kotlin.Any?){}[0]
final fun hashCode(): kotlin/Int // dev.dtrentin.chart.model/AxisConfig.hashCode|hashCode(){}[0]
final fun toString(): kotlin/String // dev.dtrentin.chart.model/AxisConfig.toString|toString(){}[0]
} }
final class dev.dtrentin.chart.model/ChartConfig { // dev.dtrentin.chart.model/ChartConfig|null[0] final class dev.dtrentin.chart.model/ChartConfig { // dev.dtrentin.chart.model/ChartConfig|null[0]
constructor <init>(kotlin/Float = ..., dev.dtrentin.chart.model/YRange = ..., dev.dtrentin.chart.model/T0 = ..., kotlin/Int? = ..., dev.dtrentin.chart.model/ChartTheme = ..., kotlin/Boolean = ..., dev.dtrentin.chart.model/AxisLabelMode = ..., dev.dtrentin.chart.model/AxisLabelMode = ..., kotlin/Int = ..., dev.dtrentin.chart.model/LodMode = ...) // dev.dtrentin.chart.model/ChartConfig.<init>|<init>(kotlin.Float;dev.dtrentin.chart.model.YRange;dev.dtrentin.chart.model.T0;kotlin.Int?;dev.dtrentin.chart.model.ChartTheme;kotlin.Boolean;dev.dtrentin.chart.model.AxisLabelMode;dev.dtrentin.chart.model.AxisLabelMode;kotlin.Int;dev.dtrentin.chart.model.LodMode){}[0] constructor <init>(dev.dtrentin.chart.model/DataConfig = ..., dev.dtrentin.chart.model/AxisConfig = ..., dev.dtrentin.chart.model/RenderConfig = ...) // dev.dtrentin.chart.model/ChartConfig.<init>|<init>(dev.dtrentin.chart.model.DataConfig;dev.dtrentin.chart.model.AxisConfig;dev.dtrentin.chart.model.RenderConfig){}[0]
final val lodMode // dev.dtrentin.chart.model/ChartConfig.lodMode|{}lodMode[0] final val axis // dev.dtrentin.chart.model/ChartConfig.axis|{}axis[0]
final fun <get-lodMode>(): dev.dtrentin.chart.model/LodMode // dev.dtrentin.chart.model/ChartConfig.lodMode.<get-lodMode>|<get-lodMode>(){}[0] final fun <get-axis>(): dev.dtrentin.chart.model/AxisConfig // dev.dtrentin.chart.model/ChartConfig.axis.<get-axis>|<get-axis>(){}[0]
final val showGrid // dev.dtrentin.chart.model/ChartConfig.showGrid|{}showGrid[0] final val data // dev.dtrentin.chart.model/ChartConfig.data|{}data[0]
final fun <get-showGrid>(): kotlin/Boolean // dev.dtrentin.chart.model/ChartConfig.showGrid.<get-showGrid>|<get-showGrid>(){}[0] final fun <get-data>(): dev.dtrentin.chart.model/DataConfig // dev.dtrentin.chart.model/ChartConfig.data.<get-data>|<get-data>(){}[0]
final val t0 // dev.dtrentin.chart.model/ChartConfig.t0|{}t0[0] final val render // dev.dtrentin.chart.model/ChartConfig.render|{}render[0]
final fun <get-t0>(): dev.dtrentin.chart.model/T0 // dev.dtrentin.chart.model/ChartConfig.t0.<get-t0>|<get-t0>(){}[0] final fun <get-render>(): dev.dtrentin.chart.model/RenderConfig // dev.dtrentin.chart.model/ChartConfig.render.<get-render>|<get-render>(){}[0]
final val targetFps // dev.dtrentin.chart.model/ChartConfig.targetFps|{}targetFps[0]
final fun <get-targetFps>(): kotlin/Int? // dev.dtrentin.chart.model/ChartConfig.targetFps.<get-targetFps>|<get-targetFps>(){}[0]
final val theme // dev.dtrentin.chart.model/ChartConfig.theme|{}theme[0] final val theme // dev.dtrentin.chart.model/ChartConfig.theme|{}theme[0]
final fun <get-theme>(): dev.dtrentin.chart.model/ChartTheme // dev.dtrentin.chart.model/ChartConfig.theme.<get-theme>|<get-theme>(){}[0] final inline fun <get-theme>(): dev.dtrentin.chart.model/ChartTheme // dev.dtrentin.chart.model/ChartConfig.theme.<get-theme>|<get-theme>(){}[0]
final val xLabelMode // dev.dtrentin.chart.model/ChartConfig.xLabelMode|{}xLabelMode[0]
final fun <get-xLabelMode>(): dev.dtrentin.chart.model/AxisLabelMode // dev.dtrentin.chart.model/ChartConfig.xLabelMode.<get-xLabelMode>|<get-xLabelMode>(){}[0]
final val xWindowSeconds // dev.dtrentin.chart.model/ChartConfig.xWindowSeconds|{}xWindowSeconds[0] final val xWindowSeconds // dev.dtrentin.chart.model/ChartConfig.xWindowSeconds|{}xWindowSeconds[0]
final fun <get-xWindowSeconds>(): kotlin/Float // dev.dtrentin.chart.model/ChartConfig.xWindowSeconds.<get-xWindowSeconds>|<get-xWindowSeconds>(){}[0] final inline fun <get-xWindowSeconds>(): kotlin/Float // dev.dtrentin.chart.model/ChartConfig.xWindowSeconds.<get-xWindowSeconds>|<get-xWindowSeconds>(){}[0]
final val yLabelDecimals // dev.dtrentin.chart.model/ChartConfig.yLabelDecimals|{}yLabelDecimals[0]
final fun <get-yLabelDecimals>(): kotlin/Int // dev.dtrentin.chart.model/ChartConfig.yLabelDecimals.<get-yLabelDecimals>|<get-yLabelDecimals>(){}[0]
final val yLabelMode // dev.dtrentin.chart.model/ChartConfig.yLabelMode|{}yLabelMode[0]
final fun <get-yLabelMode>(): dev.dtrentin.chart.model/AxisLabelMode // dev.dtrentin.chart.model/ChartConfig.yLabelMode.<get-yLabelMode>|<get-yLabelMode>(){}[0]
final val yRange // dev.dtrentin.chart.model/ChartConfig.yRange|{}yRange[0]
final fun <get-yRange>(): dev.dtrentin.chart.model/YRange // dev.dtrentin.chart.model/ChartConfig.yRange.<get-yRange>|<get-yRange>(){}[0]
final fun component1(): kotlin/Float // dev.dtrentin.chart.model/ChartConfig.component1|component1(){}[0] final fun component1(): dev.dtrentin.chart.model/DataConfig // dev.dtrentin.chart.model/ChartConfig.component1|component1(){}[0]
final fun component10(): dev.dtrentin.chart.model/LodMode // dev.dtrentin.chart.model/ChartConfig.component10|component10(){}[0] final fun component2(): dev.dtrentin.chart.model/AxisConfig // dev.dtrentin.chart.model/ChartConfig.component2|component2(){}[0]
final fun component2(): dev.dtrentin.chart.model/YRange // dev.dtrentin.chart.model/ChartConfig.component2|component2(){}[0] final fun component3(): dev.dtrentin.chart.model/RenderConfig // dev.dtrentin.chart.model/ChartConfig.component3|component3(){}[0]
final fun component3(): dev.dtrentin.chart.model/T0 // dev.dtrentin.chart.model/ChartConfig.component3|component3(){}[0] final fun copy(dev.dtrentin.chart.model/DataConfig = ..., dev.dtrentin.chart.model/AxisConfig = ..., dev.dtrentin.chart.model/RenderConfig = ...): dev.dtrentin.chart.model/ChartConfig // dev.dtrentin.chart.model/ChartConfig.copy|copy(dev.dtrentin.chart.model.DataConfig;dev.dtrentin.chart.model.AxisConfig;dev.dtrentin.chart.model.RenderConfig){}[0]
final fun component4(): kotlin/Int? // dev.dtrentin.chart.model/ChartConfig.component4|component4(){}[0]
final fun component5(): dev.dtrentin.chart.model/ChartTheme // dev.dtrentin.chart.model/ChartConfig.component5|component5(){}[0]
final fun component6(): kotlin/Boolean // dev.dtrentin.chart.model/ChartConfig.component6|component6(){}[0]
final fun component7(): dev.dtrentin.chart.model/AxisLabelMode // dev.dtrentin.chart.model/ChartConfig.component7|component7(){}[0]
final fun component8(): dev.dtrentin.chart.model/AxisLabelMode // dev.dtrentin.chart.model/ChartConfig.component8|component8(){}[0]
final fun component9(): kotlin/Int // dev.dtrentin.chart.model/ChartConfig.component9|component9(){}[0]
final fun copy(kotlin/Float = ..., dev.dtrentin.chart.model/YRange = ..., dev.dtrentin.chart.model/T0 = ..., kotlin/Int? = ..., dev.dtrentin.chart.model/ChartTheme = ..., kotlin/Boolean = ..., dev.dtrentin.chart.model/AxisLabelMode = ..., dev.dtrentin.chart.model/AxisLabelMode = ..., kotlin/Int = ..., dev.dtrentin.chart.model/LodMode = ...): dev.dtrentin.chart.model/ChartConfig // dev.dtrentin.chart.model/ChartConfig.copy|copy(kotlin.Float;dev.dtrentin.chart.model.YRange;dev.dtrentin.chart.model.T0;kotlin.Int?;dev.dtrentin.chart.model.ChartTheme;kotlin.Boolean;dev.dtrentin.chart.model.AxisLabelMode;dev.dtrentin.chart.model.AxisLabelMode;kotlin.Int;dev.dtrentin.chart.model.LodMode){}[0]
final fun equals(kotlin/Any?): kotlin/Boolean // dev.dtrentin.chart.model/ChartConfig.equals|equals(kotlin.Any?){}[0] final fun equals(kotlin/Any?): kotlin/Boolean // dev.dtrentin.chart.model/ChartConfig.equals|equals(kotlin.Any?){}[0]
final fun hashCode(): kotlin/Int // dev.dtrentin.chart.model/ChartConfig.hashCode|hashCode(){}[0] final fun hashCode(): kotlin/Int // dev.dtrentin.chart.model/ChartConfig.hashCode|hashCode(){}[0]
final fun toString(): kotlin/String // dev.dtrentin.chart.model/ChartConfig.toString|toString(){}[0] final fun toString(): kotlin/String // dev.dtrentin.chart.model/ChartConfig.toString|toString(){}[0]
@ -94,11 +205,51 @@ final class dev.dtrentin.chart.model/ChartTheme { // dev.dtrentin.chart.model/Ch
final fun toString(): kotlin/String // dev.dtrentin.chart.model/ChartTheme.toString|toString(){}[0] final fun toString(): kotlin/String // dev.dtrentin.chart.model/ChartTheme.toString|toString(){}[0]
} }
final class dev.dtrentin.chart.model/DataConfig { // dev.dtrentin.chart.model/DataConfig|null[0]
constructor <init>(kotlin/Float = ..., dev.dtrentin.chart.model/YRange = ..., dev.dtrentin.chart.model/T0 = ...) // dev.dtrentin.chart.model/DataConfig.<init>|<init>(kotlin.Float;dev.dtrentin.chart.model.YRange;dev.dtrentin.chart.model.T0){}[0]
final val t0 // dev.dtrentin.chart.model/DataConfig.t0|{}t0[0]
final fun <get-t0>(): dev.dtrentin.chart.model/T0 // dev.dtrentin.chart.model/DataConfig.t0.<get-t0>|<get-t0>(){}[0]
final val xWindowSeconds // dev.dtrentin.chart.model/DataConfig.xWindowSeconds|{}xWindowSeconds[0]
final fun <get-xWindowSeconds>(): kotlin/Float // dev.dtrentin.chart.model/DataConfig.xWindowSeconds.<get-xWindowSeconds>|<get-xWindowSeconds>(){}[0]
final val yRange // dev.dtrentin.chart.model/DataConfig.yRange|{}yRange[0]
final fun <get-yRange>(): dev.dtrentin.chart.model/YRange // dev.dtrentin.chart.model/DataConfig.yRange.<get-yRange>|<get-yRange>(){}[0]
final fun component1(): kotlin/Float // dev.dtrentin.chart.model/DataConfig.component1|component1(){}[0]
final fun component2(): dev.dtrentin.chart.model/YRange // dev.dtrentin.chart.model/DataConfig.component2|component2(){}[0]
final fun component3(): dev.dtrentin.chart.model/T0 // dev.dtrentin.chart.model/DataConfig.component3|component3(){}[0]
final fun copy(kotlin/Float = ..., dev.dtrentin.chart.model/YRange = ..., dev.dtrentin.chart.model/T0 = ...): dev.dtrentin.chart.model/DataConfig // dev.dtrentin.chart.model/DataConfig.copy|copy(kotlin.Float;dev.dtrentin.chart.model.YRange;dev.dtrentin.chart.model.T0){}[0]
final fun equals(kotlin/Any?): kotlin/Boolean // dev.dtrentin.chart.model/DataConfig.equals|equals(kotlin.Any?){}[0]
final fun hashCode(): kotlin/Int // dev.dtrentin.chart.model/DataConfig.hashCode|hashCode(){}[0]
final fun toString(): kotlin/String // dev.dtrentin.chart.model/DataConfig.toString|toString(){}[0]
}
final class dev.dtrentin.chart.model/RenderConfig { // dev.dtrentin.chart.model/RenderConfig|null[0]
constructor <init>(dev.dtrentin.chart.model/ChartTheme = ..., dev.dtrentin.chart.model/FrameRate = ..., dev.dtrentin.chart.lod/LodStrategy = ...) // dev.dtrentin.chart.model/RenderConfig.<init>|<init>(dev.dtrentin.chart.model.ChartTheme;dev.dtrentin.chart.model.FrameRate;dev.dtrentin.chart.lod.LodStrategy){}[0]
final val frameRate // dev.dtrentin.chart.model/RenderConfig.frameRate|{}frameRate[0]
final fun <get-frameRate>(): dev.dtrentin.chart.model/FrameRate // dev.dtrentin.chart.model/RenderConfig.frameRate.<get-frameRate>|<get-frameRate>(){}[0]
final val lodStrategy // dev.dtrentin.chart.model/RenderConfig.lodStrategy|{}lodStrategy[0]
final fun <get-lodStrategy>(): dev.dtrentin.chart.lod/LodStrategy // dev.dtrentin.chart.model/RenderConfig.lodStrategy.<get-lodStrategy>|<get-lodStrategy>(){}[0]
final val theme // dev.dtrentin.chart.model/RenderConfig.theme|{}theme[0]
final fun <get-theme>(): dev.dtrentin.chart.model/ChartTheme // dev.dtrentin.chart.model/RenderConfig.theme.<get-theme>|<get-theme>(){}[0]
final fun component1(): dev.dtrentin.chart.model/ChartTheme // dev.dtrentin.chart.model/RenderConfig.component1|component1(){}[0]
final fun component2(): dev.dtrentin.chart.model/FrameRate // dev.dtrentin.chart.model/RenderConfig.component2|component2(){}[0]
final fun component3(): dev.dtrentin.chart.lod/LodStrategy // dev.dtrentin.chart.model/RenderConfig.component3|component3(){}[0]
final fun copy(dev.dtrentin.chart.model/ChartTheme = ..., dev.dtrentin.chart.model/FrameRate = ..., dev.dtrentin.chart.lod/LodStrategy = ...): dev.dtrentin.chart.model/RenderConfig // dev.dtrentin.chart.model/RenderConfig.copy|copy(dev.dtrentin.chart.model.ChartTheme;dev.dtrentin.chart.model.FrameRate;dev.dtrentin.chart.lod.LodStrategy){}[0]
final fun equals(kotlin/Any?): kotlin/Boolean // dev.dtrentin.chart.model/RenderConfig.equals|equals(kotlin.Any?){}[0]
final fun hashCode(): kotlin/Int // dev.dtrentin.chart.model/RenderConfig.hashCode|hashCode(){}[0]
final fun toString(): kotlin/String // dev.dtrentin.chart.model/RenderConfig.toString|toString(){}[0]
}
final class dev.dtrentin.chart.model/SignalConfig { // dev.dtrentin.chart.model/SignalConfig|null[0] final class dev.dtrentin.chart.model/SignalConfig { // dev.dtrentin.chart.model/SignalConfig|null[0]
constructor <init>(androidx.compose.ui.graphics/Color, kotlin/Float = ..., kotlin/Boolean = ...) // dev.dtrentin.chart.model/SignalConfig.<init>|<init>(androidx.compose.ui.graphics.Color;kotlin.Float;kotlin.Boolean){}[0] constructor <init>(androidx.compose.ui.graphics/Color, kotlin/Float = ..., kotlin/Boolean = ..., dev.dtrentin.chart.render/SignalRenderer = ...) // dev.dtrentin.chart.model/SignalConfig.<init>|<init>(androidx.compose.ui.graphics.Color;kotlin.Float;kotlin.Boolean;dev.dtrentin.chart.render.SignalRenderer){}[0]
final val color // dev.dtrentin.chart.model/SignalConfig.color|{}color[0] final val color // dev.dtrentin.chart.model/SignalConfig.color|{}color[0]
final fun <get-color>(): androidx.compose.ui.graphics/Color // dev.dtrentin.chart.model/SignalConfig.color.<get-color>|<get-color>(){}[0] final fun <get-color>(): androidx.compose.ui.graphics/Color // dev.dtrentin.chart.model/SignalConfig.color.<get-color>|<get-color>(){}[0]
final val renderer // dev.dtrentin.chart.model/SignalConfig.renderer|{}renderer[0]
final fun <get-renderer>(): dev.dtrentin.chart.render/SignalRenderer // dev.dtrentin.chart.model/SignalConfig.renderer.<get-renderer>|<get-renderer>(){}[0]
final val strokeWidth // dev.dtrentin.chart.model/SignalConfig.strokeWidth|{}strokeWidth[0] final val strokeWidth // dev.dtrentin.chart.model/SignalConfig.strokeWidth|{}strokeWidth[0]
final fun <get-strokeWidth>(): kotlin/Float // dev.dtrentin.chart.model/SignalConfig.strokeWidth.<get-strokeWidth>|<get-strokeWidth>(){}[0] final fun <get-strokeWidth>(): kotlin/Float // dev.dtrentin.chart.model/SignalConfig.strokeWidth.<get-strokeWidth>|<get-strokeWidth>(){}[0]
final val visible // dev.dtrentin.chart.model/SignalConfig.visible|{}visible[0] final val visible // dev.dtrentin.chart.model/SignalConfig.visible|{}visible[0]
@ -107,12 +258,61 @@ final class dev.dtrentin.chart.model/SignalConfig { // dev.dtrentin.chart.model/
final fun component1(): androidx.compose.ui.graphics/Color // dev.dtrentin.chart.model/SignalConfig.component1|component1(){}[0] final fun component1(): androidx.compose.ui.graphics/Color // dev.dtrentin.chart.model/SignalConfig.component1|component1(){}[0]
final fun component2(): kotlin/Float // dev.dtrentin.chart.model/SignalConfig.component2|component2(){}[0] final fun component2(): kotlin/Float // dev.dtrentin.chart.model/SignalConfig.component2|component2(){}[0]
final fun component3(): kotlin/Boolean // dev.dtrentin.chart.model/SignalConfig.component3|component3(){}[0] final fun component3(): kotlin/Boolean // dev.dtrentin.chart.model/SignalConfig.component3|component3(){}[0]
final fun copy(androidx.compose.ui.graphics/Color = ..., kotlin/Float = ..., kotlin/Boolean = ...): dev.dtrentin.chart.model/SignalConfig // dev.dtrentin.chart.model/SignalConfig.copy|copy(androidx.compose.ui.graphics.Color;kotlin.Float;kotlin.Boolean){}[0] final fun component4(): dev.dtrentin.chart.render/SignalRenderer // dev.dtrentin.chart.model/SignalConfig.component4|component4(){}[0]
final fun copy(androidx.compose.ui.graphics/Color = ..., kotlin/Float = ..., kotlin/Boolean = ..., dev.dtrentin.chart.render/SignalRenderer = ...): dev.dtrentin.chart.model/SignalConfig // dev.dtrentin.chart.model/SignalConfig.copy|copy(androidx.compose.ui.graphics.Color;kotlin.Float;kotlin.Boolean;dev.dtrentin.chart.render.SignalRenderer){}[0]
final fun equals(kotlin/Any?): kotlin/Boolean // dev.dtrentin.chart.model/SignalConfig.equals|equals(kotlin.Any?){}[0] final fun equals(kotlin/Any?): kotlin/Boolean // dev.dtrentin.chart.model/SignalConfig.equals|equals(kotlin.Any?){}[0]
final fun hashCode(): kotlin/Int // dev.dtrentin.chart.model/SignalConfig.hashCode|hashCode(){}[0] final fun hashCode(): kotlin/Int // dev.dtrentin.chart.model/SignalConfig.hashCode|hashCode(){}[0]
final fun toString(): kotlin/String // dev.dtrentin.chart.model/SignalConfig.toString|toString(){}[0] final fun toString(): kotlin/String // dev.dtrentin.chart.model/SignalConfig.toString|toString(){}[0]
} }
final class dev.dtrentin.chart.render/DateTimeAxisFormatter : dev.dtrentin.chart.render/AxisFormatter { // dev.dtrentin.chart.render/DateTimeAxisFormatter|null[0]
constructor <init>(kotlin/Boolean = ...) // dev.dtrentin.chart.render/DateTimeAxisFormatter.<init>|<init>(kotlin.Boolean){}[0]
final val showSeconds // dev.dtrentin.chart.render/DateTimeAxisFormatter.showSeconds|{}showSeconds[0]
final fun <get-showSeconds>(): kotlin/Boolean // dev.dtrentin.chart.render/DateTimeAxisFormatter.showSeconds.<get-showSeconds>|<get-showSeconds>(){}[0]
final fun component1(): kotlin/Boolean // dev.dtrentin.chart.render/DateTimeAxisFormatter.component1|component1(){}[0]
final fun copy(kotlin/Boolean = ...): dev.dtrentin.chart.render/DateTimeAxisFormatter // dev.dtrentin.chart.render/DateTimeAxisFormatter.copy|copy(kotlin.Boolean){}[0]
final fun equals(kotlin/Any?): kotlin/Boolean // dev.dtrentin.chart.render/DateTimeAxisFormatter.equals|equals(kotlin.Any?){}[0]
final fun format(kotlin/Double): kotlin/String // dev.dtrentin.chart.render/DateTimeAxisFormatter.format|format(kotlin.Double){}[0]
final fun hashCode(): kotlin/Int // dev.dtrentin.chart.render/DateTimeAxisFormatter.hashCode|hashCode(){}[0]
final fun toString(): kotlin/String // dev.dtrentin.chart.render/DateTimeAxisFormatter.toString|toString(){}[0]
}
final class dev.dtrentin.chart.render/DecimalAxisFormatter : dev.dtrentin.chart.render/AxisFormatter { // dev.dtrentin.chart.render/DecimalAxisFormatter|null[0]
constructor <init>(kotlin/Int = ...) // dev.dtrentin.chart.render/DecimalAxisFormatter.<init>|<init>(kotlin.Int){}[0]
final val decimals // dev.dtrentin.chart.render/DecimalAxisFormatter.decimals|{}decimals[0]
final fun <get-decimals>(): kotlin/Int // dev.dtrentin.chart.render/DecimalAxisFormatter.decimals.<get-decimals>|<get-decimals>(){}[0]
final fun component1(): kotlin/Int // dev.dtrentin.chart.render/DecimalAxisFormatter.component1|component1(){}[0]
final fun copy(kotlin/Int = ...): dev.dtrentin.chart.render/DecimalAxisFormatter // dev.dtrentin.chart.render/DecimalAxisFormatter.copy|copy(kotlin.Int){}[0]
final fun equals(kotlin/Any?): kotlin/Boolean // dev.dtrentin.chart.render/DecimalAxisFormatter.equals|equals(kotlin.Any?){}[0]
final fun format(kotlin/Double): kotlin/String // dev.dtrentin.chart.render/DecimalAxisFormatter.format|format(kotlin.Double){}[0]
final fun hashCode(): kotlin/Int // dev.dtrentin.chart.render/DecimalAxisFormatter.hashCode|hashCode(){}[0]
final fun toString(): kotlin/String // dev.dtrentin.chart.render/DecimalAxisFormatter.toString|toString(){}[0]
}
final class dev.dtrentin.chart.render/UnitAxisFormatter : dev.dtrentin.chart.render/AxisFormatter { // dev.dtrentin.chart.render/UnitAxisFormatter|null[0]
constructor <init>(kotlin/String, kotlin/Int = ..., kotlin/String = ...) // dev.dtrentin.chart.render/UnitAxisFormatter.<init>|<init>(kotlin.String;kotlin.Int;kotlin.String){}[0]
final val decimals // dev.dtrentin.chart.render/UnitAxisFormatter.decimals|{}decimals[0]
final fun <get-decimals>(): kotlin/Int // dev.dtrentin.chart.render/UnitAxisFormatter.decimals.<get-decimals>|<get-decimals>(){}[0]
final val separator // dev.dtrentin.chart.render/UnitAxisFormatter.separator|{}separator[0]
final fun <get-separator>(): kotlin/String // dev.dtrentin.chart.render/UnitAxisFormatter.separator.<get-separator>|<get-separator>(){}[0]
final val unit // dev.dtrentin.chart.render/UnitAxisFormatter.unit|{}unit[0]
final fun <get-unit>(): kotlin/String // dev.dtrentin.chart.render/UnitAxisFormatter.unit.<get-unit>|<get-unit>(){}[0]
final fun component1(): kotlin/String // dev.dtrentin.chart.render/UnitAxisFormatter.component1|component1(){}[0]
final fun component2(): kotlin/Int // dev.dtrentin.chart.render/UnitAxisFormatter.component2|component2(){}[0]
final fun component3(): kotlin/String // dev.dtrentin.chart.render/UnitAxisFormatter.component3|component3(){}[0]
final fun copy(kotlin/String = ..., kotlin/Int = ..., kotlin/String = ...): dev.dtrentin.chart.render/UnitAxisFormatter // dev.dtrentin.chart.render/UnitAxisFormatter.copy|copy(kotlin.String;kotlin.Int;kotlin.String){}[0]
final fun equals(kotlin/Any?): kotlin/Boolean // dev.dtrentin.chart.render/UnitAxisFormatter.equals|equals(kotlin.Any?){}[0]
final fun format(kotlin/Double): kotlin/String // dev.dtrentin.chart.render/UnitAxisFormatter.format|format(kotlin.Double){}[0]
final fun hashCode(): kotlin/Int // dev.dtrentin.chart.render/UnitAxisFormatter.hashCode|hashCode(){}[0]
final fun toString(): kotlin/String // dev.dtrentin.chart.render/UnitAxisFormatter.toString|toString(){}[0]
}
final class dev.dtrentin.chart/RealtimeChartState { // dev.dtrentin.chart/RealtimeChartState|null[0] final class dev.dtrentin.chart/RealtimeChartState { // dev.dtrentin.chart/RealtimeChartState|null[0]
constructor <init>(dev.dtrentin.chart.model/ChartConfig = ...) // dev.dtrentin.chart/RealtimeChartState.<init>|<init>(dev.dtrentin.chart.model.ChartConfig){}[0] constructor <init>(dev.dtrentin.chart.model/ChartConfig = ...) // dev.dtrentin.chart/RealtimeChartState.<init>|<init>(dev.dtrentin.chart.model.ChartConfig){}[0]
@ -120,12 +320,61 @@ final class dev.dtrentin.chart/RealtimeChartState { // dev.dtrentin.chart/Realti
final fun <get-config>(): dev.dtrentin.chart.model/ChartConfig // dev.dtrentin.chart/RealtimeChartState.config.<get-config>|<get-config>(){}[0] final fun <get-config>(): dev.dtrentin.chart.model/ChartConfig // dev.dtrentin.chart/RealtimeChartState.config.<get-config>|<get-config>(){}[0]
final fun addSignal(kotlin/String, dev.dtrentin.chart.model/SignalConfig) // dev.dtrentin.chart/RealtimeChartState.addSignal|addSignal(kotlin.String;dev.dtrentin.chart.model.SignalConfig){}[0] final fun addSignal(kotlin/String, dev.dtrentin.chart.model/SignalConfig) // dev.dtrentin.chart/RealtimeChartState.addSignal|addSignal(kotlin.String;dev.dtrentin.chart.model.SignalConfig){}[0]
final fun clear() // dev.dtrentin.chart/RealtimeChartState.clear|clear(){}[0]
final fun collectFrom(kotlin/String, kotlinx.coroutines.flow/Flow<kotlin/Float>, kotlinx.coroutines/CoroutineScope): kotlinx.coroutines/Job // dev.dtrentin.chart/RealtimeChartState.collectFrom|collectFrom(kotlin.String;kotlinx.coroutines.flow.Flow<kotlin.Float>;kotlinx.coroutines.CoroutineScope){}[0] final fun collectFrom(kotlin/String, kotlinx.coroutines.flow/Flow<kotlin/Float>, kotlinx.coroutines/CoroutineScope): kotlinx.coroutines/Job // dev.dtrentin.chart/RealtimeChartState.collectFrom|collectFrom(kotlin.String;kotlinx.coroutines.flow.Flow<kotlin.Float>;kotlinx.coroutines.CoroutineScope){}[0]
final fun collectFrom(kotlin/String, kotlinx.coroutines.flow/Flow<kotlin/Pair<kotlin/Long, kotlin/Float>>, kotlinx.coroutines/CoroutineScope): kotlinx.coroutines/Job // dev.dtrentin.chart/RealtimeChartState.collectFrom|collectFrom(kotlin.String;kotlinx.coroutines.flow.Flow<kotlin.Pair<kotlin.Long,kotlin.Float>>;kotlinx.coroutines.CoroutineScope){}[0] final fun collectFrom(kotlin/String, kotlinx.coroutines.flow/Flow<kotlin/Pair<kotlin/Long, kotlin/Float>>, kotlinx.coroutines/CoroutineScope): kotlinx.coroutines/Job // dev.dtrentin.chart/RealtimeChartState.collectFrom|collectFrom(kotlin.String;kotlinx.coroutines.flow.Flow<kotlin.Pair<kotlin.Long,kotlin.Float>>;kotlinx.coroutines.CoroutineScope){}[0]
final fun push(kotlin/String, kotlin/Long, kotlin/Float) // dev.dtrentin.chart/RealtimeChartState.push|push(kotlin.String;kotlin.Long;kotlin.Float){}[0] final fun push(kotlin/String, kotlin/Long, kotlin/Float) // dev.dtrentin.chart/RealtimeChartState.push|push(kotlin.String;kotlin.Long;kotlin.Float){}[0]
final fun removeSignal(kotlin/String) // dev.dtrentin.chart/RealtimeChartState.removeSignal|removeSignal(kotlin.String){}[0] final fun removeSignal(kotlin/String) // dev.dtrentin.chart/RealtimeChartState.removeSignal|removeSignal(kotlin.String){}[0]
} }
sealed class dev.dtrentin.chart.interaction/ViewportMode { // dev.dtrentin.chart.interaction/ViewportMode|null[0]
final class History : dev.dtrentin.chart.interaction/ViewportMode { // dev.dtrentin.chart.interaction/ViewportMode.History|null[0]
constructor <init>(kotlin/Long) // dev.dtrentin.chart.interaction/ViewportMode.History.<init>|<init>(kotlin.Long){}[0]
final val anchorMs // dev.dtrentin.chart.interaction/ViewportMode.History.anchorMs|{}anchorMs[0]
final fun <get-anchorMs>(): kotlin/Long // dev.dtrentin.chart.interaction/ViewportMode.History.anchorMs.<get-anchorMs>|<get-anchorMs>(){}[0]
final fun component1(): kotlin/Long // dev.dtrentin.chart.interaction/ViewportMode.History.component1|component1(){}[0]
final fun copy(kotlin/Long = ...): dev.dtrentin.chart.interaction/ViewportMode.History // dev.dtrentin.chart.interaction/ViewportMode.History.copy|copy(kotlin.Long){}[0]
final fun equals(kotlin/Any?): kotlin/Boolean // dev.dtrentin.chart.interaction/ViewportMode.History.equals|equals(kotlin.Any?){}[0]
final fun hashCode(): kotlin/Int // dev.dtrentin.chart.interaction/ViewportMode.History.hashCode|hashCode(){}[0]
final fun toString(): kotlin/String // dev.dtrentin.chart.interaction/ViewportMode.History.toString|toString(){}[0]
}
final object Following : dev.dtrentin.chart.interaction/ViewportMode { // dev.dtrentin.chart.interaction/ViewportMode.Following|null[0]
final fun equals(kotlin/Any?): kotlin/Boolean // dev.dtrentin.chart.interaction/ViewportMode.Following.equals|equals(kotlin.Any?){}[0]
final fun hashCode(): kotlin/Int // dev.dtrentin.chart.interaction/ViewportMode.Following.hashCode|hashCode(){}[0]
final fun toString(): kotlin/String // dev.dtrentin.chart.interaction/ViewportMode.Following.toString|toString(){}[0]
}
final object Frozen : dev.dtrentin.chart.interaction/ViewportMode { // dev.dtrentin.chart.interaction/ViewportMode.Frozen|null[0]
final fun equals(kotlin/Any?): kotlin/Boolean // dev.dtrentin.chart.interaction/ViewportMode.Frozen.equals|equals(kotlin.Any?){}[0]
final fun hashCode(): kotlin/Int // dev.dtrentin.chart.interaction/ViewportMode.Frozen.hashCode|hashCode(){}[0]
final fun toString(): kotlin/String // dev.dtrentin.chart.interaction/ViewportMode.Frozen.toString|toString(){}[0]
}
}
sealed class dev.dtrentin.chart.model/FrameRate { // dev.dtrentin.chart.model/FrameRate|null[0]
final class Fixed : dev.dtrentin.chart.model/FrameRate { // dev.dtrentin.chart.model/FrameRate.Fixed|null[0]
constructor <init>(kotlin/Int) // dev.dtrentin.chart.model/FrameRate.Fixed.<init>|<init>(kotlin.Int){}[0]
final val fps // dev.dtrentin.chart.model/FrameRate.Fixed.fps|{}fps[0]
final fun <get-fps>(): kotlin/Int // dev.dtrentin.chart.model/FrameRate.Fixed.fps.<get-fps>|<get-fps>(){}[0]
final fun component1(): kotlin/Int // dev.dtrentin.chart.model/FrameRate.Fixed.component1|component1(){}[0]
final fun copy(kotlin/Int = ...): dev.dtrentin.chart.model/FrameRate.Fixed // dev.dtrentin.chart.model/FrameRate.Fixed.copy|copy(kotlin.Int){}[0]
final fun equals(kotlin/Any?): kotlin/Boolean // dev.dtrentin.chart.model/FrameRate.Fixed.equals|equals(kotlin.Any?){}[0]
final fun hashCode(): kotlin/Int // dev.dtrentin.chart.model/FrameRate.Fixed.hashCode|hashCode(){}[0]
final fun toString(): kotlin/String // dev.dtrentin.chart.model/FrameRate.Fixed.toString|toString(){}[0]
}
final object Display : dev.dtrentin.chart.model/FrameRate { // dev.dtrentin.chart.model/FrameRate.Display|null[0]
final fun equals(kotlin/Any?): kotlin/Boolean // dev.dtrentin.chart.model/FrameRate.Display.equals|equals(kotlin.Any?){}[0]
final fun hashCode(): kotlin/Int // dev.dtrentin.chart.model/FrameRate.Display.hashCode|hashCode(){}[0]
final fun toString(): kotlin/String // dev.dtrentin.chart.model/FrameRate.Display.toString|toString(){}[0]
}
}
sealed class dev.dtrentin.chart.model/T0 { // dev.dtrentin.chart.model/T0|null[0] sealed class dev.dtrentin.chart.model/T0 { // dev.dtrentin.chart.model/T0|null[0]
final class Fixed : dev.dtrentin.chart.model/T0 { // dev.dtrentin.chart.model/T0.Fixed|null[0] final class Fixed : dev.dtrentin.chart.model/T0 { // dev.dtrentin.chart.model/T0.Fixed|null[0]
constructor <init>(kotlin/Long) // dev.dtrentin.chart.model/T0.Fixed.<init>|<init>(kotlin.Long){}[0] constructor <init>(kotlin/Long) // dev.dtrentin.chart.model/T0.Fixed.<init>|<init>(kotlin.Long){}[0]
@ -174,11 +423,33 @@ sealed class dev.dtrentin.chart.model/YRange { // dev.dtrentin.chart.model/YRang
} }
} }
final val dev.dtrentin.chart.buffer/dev_dtrentin_chart_buffer_CircularBuffer$stableprop // dev.dtrentin.chart.buffer/dev_dtrentin_chart_buffer_CircularBuffer$stableprop|#static{}dev_dtrentin_chart_buffer_CircularBuffer$stableprop[0] final object dev.dtrentin.chart.render/LineSignalRenderer : dev.dtrentin.chart.render/SignalRenderer { // dev.dtrentin.chart.render/LineSignalRenderer|null[0]
final val dev.dtrentin.chart.buffer/dev_dtrentin_chart_buffer_LodDecimator$stableprop // dev.dtrentin.chart.buffer/dev_dtrentin_chart_buffer_LodDecimator$stableprop|#static{}dev_dtrentin_chart_buffer_LodDecimator$stableprop[0] final fun (androidx.compose.ui.graphics.drawscope/DrawScope).drawSignal(androidx.compose.ui.graphics/Color, kotlin/Float, kotlin/Boolean, kotlin/FloatArray, kotlin/FloatArray, kotlin/Int, androidx.compose.ui.graphics/Path, kotlin/Float, kotlin/Float, kotlin/Float, kotlin/Float, kotlin/Float) // dev.dtrentin.chart.render/LineSignalRenderer.drawSignal|drawSignal@androidx.compose.ui.graphics.drawscope.DrawScope(androidx.compose.ui.graphics.Color;kotlin.Float;kotlin.Boolean;kotlin.FloatArray;kotlin.FloatArray;kotlin.Int;androidx.compose.ui.graphics.Path;kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float){}[0]
final val dev.dtrentin.chart.buffer/dev_dtrentin_chart_buffer_TieredBuffer$stableprop // dev.dtrentin.chart.buffer/dev_dtrentin_chart_buffer_TieredBuffer$stableprop|#static{}dev_dtrentin_chart_buffer_TieredBuffer$stableprop[0] }
final object dev.dtrentin.chart.render/TimeAxisFormatter : dev.dtrentin.chart.render/AxisFormatter { // dev.dtrentin.chart.render/TimeAxisFormatter|null[0]
final fun format(kotlin/Double): kotlin/String // dev.dtrentin.chart.render/TimeAxisFormatter.format|format(kotlin.Double){}[0]
}
final val dev.dtrentin.chart.interaction/dev_dtrentin_chart_interaction_ChartInteractionState$stableprop // dev.dtrentin.chart.interaction/dev_dtrentin_chart_interaction_ChartInteractionState$stableprop|#static{}dev_dtrentin_chart_interaction_ChartInteractionState$stableprop[0]
final val dev.dtrentin.chart.interaction/dev_dtrentin_chart_interaction_CrosshairState$stableprop // dev.dtrentin.chart.interaction/dev_dtrentin_chart_interaction_CrosshairState$stableprop|#static{}dev_dtrentin_chart_interaction_CrosshairState$stableprop[0]
final val dev.dtrentin.chart.interaction/dev_dtrentin_chart_interaction_InteractionConfig$stableprop // dev.dtrentin.chart.interaction/dev_dtrentin_chart_interaction_InteractionConfig$stableprop|#static{}dev_dtrentin_chart_interaction_InteractionConfig$stableprop[0]
final val dev.dtrentin.chart.interaction/dev_dtrentin_chart_interaction_SignalValueAt$stableprop // dev.dtrentin.chart.interaction/dev_dtrentin_chart_interaction_SignalValueAt$stableprop|#static{}dev_dtrentin_chart_interaction_SignalValueAt$stableprop[0]
final val dev.dtrentin.chart.interaction/dev_dtrentin_chart_interaction_ViewportMode$stableprop // dev.dtrentin.chart.interaction/dev_dtrentin_chart_interaction_ViewportMode$stableprop|#static{}dev_dtrentin_chart_interaction_ViewportMode$stableprop[0]
final val dev.dtrentin.chart.interaction/dev_dtrentin_chart_interaction_ViewportMode_Following$stableprop // dev.dtrentin.chart.interaction/dev_dtrentin_chart_interaction_ViewportMode_Following$stableprop|#static{}dev_dtrentin_chart_interaction_ViewportMode_Following$stableprop[0]
final val dev.dtrentin.chart.interaction/dev_dtrentin_chart_interaction_ViewportMode_Frozen$stableprop // dev.dtrentin.chart.interaction/dev_dtrentin_chart_interaction_ViewportMode_Frozen$stableprop|#static{}dev_dtrentin_chart_interaction_ViewportMode_Frozen$stableprop[0]
final val dev.dtrentin.chart.interaction/dev_dtrentin_chart_interaction_ViewportMode_History$stableprop // dev.dtrentin.chart.interaction/dev_dtrentin_chart_interaction_ViewportMode_History$stableprop|#static{}dev_dtrentin_chart_interaction_ViewportMode_History$stableprop[0]
final val dev.dtrentin.chart.lod/dev_dtrentin_chart_lod_LttbLodStrategy$stableprop // dev.dtrentin.chart.lod/dev_dtrentin_chart_lod_LttbLodStrategy$stableprop|#static{}dev_dtrentin_chart_lod_LttbLodStrategy$stableprop[0]
final val dev.dtrentin.chart.lod/dev_dtrentin_chart_lod_MinMaxLodStrategy$stableprop // dev.dtrentin.chart.lod/dev_dtrentin_chart_lod_MinMaxLodStrategy$stableprop|#static{}dev_dtrentin_chart_lod_MinMaxLodStrategy$stableprop[0]
final val dev.dtrentin.chart.lod/dev_dtrentin_chart_lod_MinMaxLttbLodStrategy$stableprop // dev.dtrentin.chart.lod/dev_dtrentin_chart_lod_MinMaxLttbLodStrategy$stableprop|#static{}dev_dtrentin_chart_lod_MinMaxLttbLodStrategy$stableprop[0]
final val dev.dtrentin.chart.model/dev_dtrentin_chart_model_AxisConfig$stableprop // dev.dtrentin.chart.model/dev_dtrentin_chart_model_AxisConfig$stableprop|#static{}dev_dtrentin_chart_model_AxisConfig$stableprop[0]
final val dev.dtrentin.chart.model/dev_dtrentin_chart_model_ChartConfig$stableprop // dev.dtrentin.chart.model/dev_dtrentin_chart_model_ChartConfig$stableprop|#static{}dev_dtrentin_chart_model_ChartConfig$stableprop[0] final val dev.dtrentin.chart.model/dev_dtrentin_chart_model_ChartConfig$stableprop // dev.dtrentin.chart.model/dev_dtrentin_chart_model_ChartConfig$stableprop|#static{}dev_dtrentin_chart_model_ChartConfig$stableprop[0]
final val dev.dtrentin.chart.model/dev_dtrentin_chart_model_ChartTheme$stableprop // dev.dtrentin.chart.model/dev_dtrentin_chart_model_ChartTheme$stableprop|#static{}dev_dtrentin_chart_model_ChartTheme$stableprop[0] final val dev.dtrentin.chart.model/dev_dtrentin_chart_model_ChartTheme$stableprop // dev.dtrentin.chart.model/dev_dtrentin_chart_model_ChartTheme$stableprop|#static{}dev_dtrentin_chart_model_ChartTheme$stableprop[0]
final val dev.dtrentin.chart.model/dev_dtrentin_chart_model_DataConfig$stableprop // dev.dtrentin.chart.model/dev_dtrentin_chart_model_DataConfig$stableprop|#static{}dev_dtrentin_chart_model_DataConfig$stableprop[0]
final val dev.dtrentin.chart.model/dev_dtrentin_chart_model_FrameRate$stableprop // dev.dtrentin.chart.model/dev_dtrentin_chart_model_FrameRate$stableprop|#static{}dev_dtrentin_chart_model_FrameRate$stableprop[0]
final val dev.dtrentin.chart.model/dev_dtrentin_chart_model_FrameRate_Display$stableprop // dev.dtrentin.chart.model/dev_dtrentin_chart_model_FrameRate_Display$stableprop|#static{}dev_dtrentin_chart_model_FrameRate_Display$stableprop[0]
final val dev.dtrentin.chart.model/dev_dtrentin_chart_model_FrameRate_Fixed$stableprop // dev.dtrentin.chart.model/dev_dtrentin_chart_model_FrameRate_Fixed$stableprop|#static{}dev_dtrentin_chart_model_FrameRate_Fixed$stableprop[0]
final val dev.dtrentin.chart.model/dev_dtrentin_chart_model_RenderConfig$stableprop // dev.dtrentin.chart.model/dev_dtrentin_chart_model_RenderConfig$stableprop|#static{}dev_dtrentin_chart_model_RenderConfig$stableprop[0]
final val dev.dtrentin.chart.model/dev_dtrentin_chart_model_SignalConfig$stableprop // dev.dtrentin.chart.model/dev_dtrentin_chart_model_SignalConfig$stableprop|#static{}dev_dtrentin_chart_model_SignalConfig$stableprop[0] final val dev.dtrentin.chart.model/dev_dtrentin_chart_model_SignalConfig$stableprop // dev.dtrentin.chart.model/dev_dtrentin_chart_model_SignalConfig$stableprop|#static{}dev_dtrentin_chart_model_SignalConfig$stableprop[0]
final val dev.dtrentin.chart.model/dev_dtrentin_chart_model_T0$stableprop // dev.dtrentin.chart.model/dev_dtrentin_chart_model_T0$stableprop|#static{}dev_dtrentin_chart_model_T0$stableprop[0] final val dev.dtrentin.chart.model/dev_dtrentin_chart_model_T0$stableprop // dev.dtrentin.chart.model/dev_dtrentin_chart_model_T0$stableprop|#static{}dev_dtrentin_chart_model_T0$stableprop[0]
final val dev.dtrentin.chart.model/dev_dtrentin_chart_model_T0_FirstSample$stableprop // dev.dtrentin.chart.model/dev_dtrentin_chart_model_T0_FirstSample$stableprop|#static{}dev_dtrentin_chart_model_T0_FirstSample$stableprop[0] final val dev.dtrentin.chart.model/dev_dtrentin_chart_model_T0_FirstSample$stableprop // dev.dtrentin.chart.model/dev_dtrentin_chart_model_T0_FirstSample$stableprop|#static{}dev_dtrentin_chart_model_T0_FirstSample$stableprop[0]
@ -186,17 +457,33 @@ final val dev.dtrentin.chart.model/dev_dtrentin_chart_model_T0_Fixed$stableprop
final val dev.dtrentin.chart.model/dev_dtrentin_chart_model_YRange$stableprop // dev.dtrentin.chart.model/dev_dtrentin_chart_model_YRange$stableprop|#static{}dev_dtrentin_chart_model_YRange$stableprop[0] final val dev.dtrentin.chart.model/dev_dtrentin_chart_model_YRange$stableprop // dev.dtrentin.chart.model/dev_dtrentin_chart_model_YRange$stableprop|#static{}dev_dtrentin_chart_model_YRange$stableprop[0]
final val dev.dtrentin.chart.model/dev_dtrentin_chart_model_YRange_Auto$stableprop // dev.dtrentin.chart.model/dev_dtrentin_chart_model_YRange_Auto$stableprop|#static{}dev_dtrentin_chart_model_YRange_Auto$stableprop[0] final val dev.dtrentin.chart.model/dev_dtrentin_chart_model_YRange_Auto$stableprop // dev.dtrentin.chart.model/dev_dtrentin_chart_model_YRange_Auto$stableprop|#static{}dev_dtrentin_chart_model_YRange_Auto$stableprop[0]
final val dev.dtrentin.chart.model/dev_dtrentin_chart_model_YRange_Fixed$stableprop // dev.dtrentin.chart.model/dev_dtrentin_chart_model_YRange_Fixed$stableprop|#static{}dev_dtrentin_chart_model_YRange_Fixed$stableprop[0] final val dev.dtrentin.chart.model/dev_dtrentin_chart_model_YRange_Fixed$stableprop // dev.dtrentin.chart.model/dev_dtrentin_chart_model_YRange_Fixed$stableprop|#static{}dev_dtrentin_chart_model_YRange_Fixed$stableprop[0]
final val dev.dtrentin.chart.render/dev_dtrentin_chart_render_AxisRenderer$stableprop // dev.dtrentin.chart.render/dev_dtrentin_chart_render_AxisRenderer$stableprop|#static{}dev_dtrentin_chart_render_AxisRenderer$stableprop[0] final val dev.dtrentin.chart.render/dev_dtrentin_chart_render_DateTimeAxisFormatter$stableprop // dev.dtrentin.chart.render/dev_dtrentin_chart_render_DateTimeAxisFormatter$stableprop|#static{}dev_dtrentin_chart_render_DateTimeAxisFormatter$stableprop[0]
final val dev.dtrentin.chart.render/dev_dtrentin_chart_render_NumberFormat$stableprop // dev.dtrentin.chart.render/dev_dtrentin_chart_render_NumberFormat$stableprop|#static{}dev_dtrentin_chart_render_NumberFormat$stableprop[0] final val dev.dtrentin.chart.render/dev_dtrentin_chart_render_DecimalAxisFormatter$stableprop // dev.dtrentin.chart.render/dev_dtrentin_chart_render_DecimalAxisFormatter$stableprop|#static{}dev_dtrentin_chart_render_DecimalAxisFormatter$stableprop[0]
final val dev.dtrentin.chart.render/dev_dtrentin_chart_render_SignalRenderer$stableprop // dev.dtrentin.chart.render/dev_dtrentin_chart_render_SignalRenderer$stableprop|#static{}dev_dtrentin_chart_render_SignalRenderer$stableprop[0] final val dev.dtrentin.chart.render/dev_dtrentin_chart_render_LineSignalRenderer$stableprop // dev.dtrentin.chart.render/dev_dtrentin_chart_render_LineSignalRenderer$stableprop|#static{}dev_dtrentin_chart_render_LineSignalRenderer$stableprop[0]
final val dev.dtrentin.chart.render/dev_dtrentin_chart_render_TimeAxisFormatter$stableprop // dev.dtrentin.chart.render/dev_dtrentin_chart_render_TimeAxisFormatter$stableprop|#static{}dev_dtrentin_chart_render_TimeAxisFormatter$stableprop[0]
final val dev.dtrentin.chart.render/dev_dtrentin_chart_render_UnitAxisFormatter$stableprop // dev.dtrentin.chart.render/dev_dtrentin_chart_render_UnitAxisFormatter$stableprop|#static{}dev_dtrentin_chart_render_UnitAxisFormatter$stableprop[0]
final val dev.dtrentin.chart/dev_dtrentin_chart_RealtimeChartState$stableprop // dev.dtrentin.chart/dev_dtrentin_chart_RealtimeChartState$stableprop|#static{}dev_dtrentin_chart_RealtimeChartState$stableprop[0] final val dev.dtrentin.chart/dev_dtrentin_chart_RealtimeChartState$stableprop // dev.dtrentin.chart/dev_dtrentin_chart_RealtimeChartState$stableprop|#static{}dev_dtrentin_chart_RealtimeChartState$stableprop[0]
final val dev.dtrentin.chart/dev_dtrentin_chart_SignalEntry$stableprop // dev.dtrentin.chart/dev_dtrentin_chart_SignalEntry$stableprop|#static{}dev_dtrentin_chart_SignalEntry$stableprop[0]
final fun dev.dtrentin.chart.buffer/dev_dtrentin_chart_buffer_CircularBuffer$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.buffer/dev_dtrentin_chart_buffer_CircularBuffer$stableprop_getter|dev_dtrentin_chart_buffer_CircularBuffer$stableprop_getter(){}[0] final fun dev.dtrentin.chart.interaction/dev_dtrentin_chart_interaction_ChartInteractionState$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.interaction/dev_dtrentin_chart_interaction_ChartInteractionState$stableprop_getter|dev_dtrentin_chart_interaction_ChartInteractionState$stableprop_getter(){}[0]
final fun dev.dtrentin.chart.buffer/dev_dtrentin_chart_buffer_LodDecimator$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.buffer/dev_dtrentin_chart_buffer_LodDecimator$stableprop_getter|dev_dtrentin_chart_buffer_LodDecimator$stableprop_getter(){}[0] final fun dev.dtrentin.chart.interaction/dev_dtrentin_chart_interaction_CrosshairState$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.interaction/dev_dtrentin_chart_interaction_CrosshairState$stableprop_getter|dev_dtrentin_chart_interaction_CrosshairState$stableprop_getter(){}[0]
final fun dev.dtrentin.chart.buffer/dev_dtrentin_chart_buffer_TieredBuffer$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.buffer/dev_dtrentin_chart_buffer_TieredBuffer$stableprop_getter|dev_dtrentin_chart_buffer_TieredBuffer$stableprop_getter(){}[0] final fun dev.dtrentin.chart.interaction/dev_dtrentin_chart_interaction_InteractionConfig$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.interaction/dev_dtrentin_chart_interaction_InteractionConfig$stableprop_getter|dev_dtrentin_chart_interaction_InteractionConfig$stableprop_getter(){}[0]
final fun dev.dtrentin.chart.interaction/dev_dtrentin_chart_interaction_SignalValueAt$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.interaction/dev_dtrentin_chart_interaction_SignalValueAt$stableprop_getter|dev_dtrentin_chart_interaction_SignalValueAt$stableprop_getter(){}[0]
final fun dev.dtrentin.chart.interaction/dev_dtrentin_chart_interaction_ViewportMode$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.interaction/dev_dtrentin_chart_interaction_ViewportMode$stableprop_getter|dev_dtrentin_chart_interaction_ViewportMode$stableprop_getter(){}[0]
final fun dev.dtrentin.chart.interaction/dev_dtrentin_chart_interaction_ViewportMode_Following$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.interaction/dev_dtrentin_chart_interaction_ViewportMode_Following$stableprop_getter|dev_dtrentin_chart_interaction_ViewportMode_Following$stableprop_getter(){}[0]
final fun dev.dtrentin.chart.interaction/dev_dtrentin_chart_interaction_ViewportMode_Frozen$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.interaction/dev_dtrentin_chart_interaction_ViewportMode_Frozen$stableprop_getter|dev_dtrentin_chart_interaction_ViewportMode_Frozen$stableprop_getter(){}[0]
final fun dev.dtrentin.chart.interaction/dev_dtrentin_chart_interaction_ViewportMode_History$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.interaction/dev_dtrentin_chart_interaction_ViewportMode_History$stableprop_getter|dev_dtrentin_chart_interaction_ViewportMode_History$stableprop_getter(){}[0]
final fun dev.dtrentin.chart.interaction/rememberChartInteractionState(dev.dtrentin.chart.interaction/InteractionConfig?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): dev.dtrentin.chart.interaction/ChartInteractionState // dev.dtrentin.chart.interaction/rememberChartInteractionState|rememberChartInteractionState(dev.dtrentin.chart.interaction.InteractionConfig?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
final fun dev.dtrentin.chart.lod/dev_dtrentin_chart_lod_LttbLodStrategy$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.lod/dev_dtrentin_chart_lod_LttbLodStrategy$stableprop_getter|dev_dtrentin_chart_lod_LttbLodStrategy$stableprop_getter(){}[0]
final fun dev.dtrentin.chart.lod/dev_dtrentin_chart_lod_MinMaxLodStrategy$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.lod/dev_dtrentin_chart_lod_MinMaxLodStrategy$stableprop_getter|dev_dtrentin_chart_lod_MinMaxLodStrategy$stableprop_getter(){}[0]
final fun dev.dtrentin.chart.lod/dev_dtrentin_chart_lod_MinMaxLttbLodStrategy$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.lod/dev_dtrentin_chart_lod_MinMaxLttbLodStrategy$stableprop_getter|dev_dtrentin_chart_lod_MinMaxLttbLodStrategy$stableprop_getter(){}[0]
final fun dev.dtrentin.chart.model/dev_dtrentin_chart_model_AxisConfig$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.model/dev_dtrentin_chart_model_AxisConfig$stableprop_getter|dev_dtrentin_chart_model_AxisConfig$stableprop_getter(){}[0]
final fun dev.dtrentin.chart.model/dev_dtrentin_chart_model_ChartConfig$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.model/dev_dtrentin_chart_model_ChartConfig$stableprop_getter|dev_dtrentin_chart_model_ChartConfig$stableprop_getter(){}[0] final fun dev.dtrentin.chart.model/dev_dtrentin_chart_model_ChartConfig$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.model/dev_dtrentin_chart_model_ChartConfig$stableprop_getter|dev_dtrentin_chart_model_ChartConfig$stableprop_getter(){}[0]
final fun dev.dtrentin.chart.model/dev_dtrentin_chart_model_ChartTheme$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.model/dev_dtrentin_chart_model_ChartTheme$stableprop_getter|dev_dtrentin_chart_model_ChartTheme$stableprop_getter(){}[0] final fun dev.dtrentin.chart.model/dev_dtrentin_chart_model_ChartTheme$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.model/dev_dtrentin_chart_model_ChartTheme$stableprop_getter|dev_dtrentin_chart_model_ChartTheme$stableprop_getter(){}[0]
final fun dev.dtrentin.chart.model/dev_dtrentin_chart_model_DataConfig$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.model/dev_dtrentin_chart_model_DataConfig$stableprop_getter|dev_dtrentin_chart_model_DataConfig$stableprop_getter(){}[0]
final fun dev.dtrentin.chart.model/dev_dtrentin_chart_model_FrameRate$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.model/dev_dtrentin_chart_model_FrameRate$stableprop_getter|dev_dtrentin_chart_model_FrameRate$stableprop_getter(){}[0]
final fun dev.dtrentin.chart.model/dev_dtrentin_chart_model_FrameRate_Display$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.model/dev_dtrentin_chart_model_FrameRate_Display$stableprop_getter|dev_dtrentin_chart_model_FrameRate_Display$stableprop_getter(){}[0]
final fun dev.dtrentin.chart.model/dev_dtrentin_chart_model_FrameRate_Fixed$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.model/dev_dtrentin_chart_model_FrameRate_Fixed$stableprop_getter|dev_dtrentin_chart_model_FrameRate_Fixed$stableprop_getter(){}[0]
final fun dev.dtrentin.chart.model/dev_dtrentin_chart_model_RenderConfig$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.model/dev_dtrentin_chart_model_RenderConfig$stableprop_getter|dev_dtrentin_chart_model_RenderConfig$stableprop_getter(){}[0]
final fun dev.dtrentin.chart.model/dev_dtrentin_chart_model_SignalConfig$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.model/dev_dtrentin_chart_model_SignalConfig$stableprop_getter|dev_dtrentin_chart_model_SignalConfig$stableprop_getter(){}[0] final fun dev.dtrentin.chart.model/dev_dtrentin_chart_model_SignalConfig$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.model/dev_dtrentin_chart_model_SignalConfig$stableprop_getter|dev_dtrentin_chart_model_SignalConfig$stableprop_getter(){}[0]
final fun dev.dtrentin.chart.model/dev_dtrentin_chart_model_T0$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.model/dev_dtrentin_chart_model_T0$stableprop_getter|dev_dtrentin_chart_model_T0$stableprop_getter(){}[0] final fun dev.dtrentin.chart.model/dev_dtrentin_chart_model_T0$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.model/dev_dtrentin_chart_model_T0$stableprop_getter|dev_dtrentin_chart_model_T0$stableprop_getter(){}[0]
final fun dev.dtrentin.chart.model/dev_dtrentin_chart_model_T0_FirstSample$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.model/dev_dtrentin_chart_model_T0_FirstSample$stableprop_getter|dev_dtrentin_chart_model_T0_FirstSample$stableprop_getter(){}[0] final fun dev.dtrentin.chart.model/dev_dtrentin_chart_model_T0_FirstSample$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.model/dev_dtrentin_chart_model_T0_FirstSample$stableprop_getter|dev_dtrentin_chart_model_T0_FirstSample$stableprop_getter(){}[0]
@ -205,9 +492,10 @@ final fun dev.dtrentin.chart.model/dev_dtrentin_chart_model_YRange$stableprop_ge
final fun dev.dtrentin.chart.model/dev_dtrentin_chart_model_YRange_Auto$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.model/dev_dtrentin_chart_model_YRange_Auto$stableprop_getter|dev_dtrentin_chart_model_YRange_Auto$stableprop_getter(){}[0] final fun dev.dtrentin.chart.model/dev_dtrentin_chart_model_YRange_Auto$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.model/dev_dtrentin_chart_model_YRange_Auto$stableprop_getter|dev_dtrentin_chart_model_YRange_Auto$stableprop_getter(){}[0]
final fun dev.dtrentin.chart.model/dev_dtrentin_chart_model_YRange_Fixed$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.model/dev_dtrentin_chart_model_YRange_Fixed$stableprop_getter|dev_dtrentin_chart_model_YRange_Fixed$stableprop_getter(){}[0] final fun dev.dtrentin.chart.model/dev_dtrentin_chart_model_YRange_Fixed$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.model/dev_dtrentin_chart_model_YRange_Fixed$stableprop_getter|dev_dtrentin_chart_model_YRange_Fixed$stableprop_getter(){}[0]
final fun dev.dtrentin.chart.model/rememberMaterialChartTheme(androidx.compose.runtime/Composer?, kotlin/Int): dev.dtrentin.chart.model/ChartTheme // dev.dtrentin.chart.model/rememberMaterialChartTheme|rememberMaterialChartTheme(androidx.compose.runtime.Composer?;kotlin.Int){}[0] final fun dev.dtrentin.chart.model/rememberMaterialChartTheme(androidx.compose.runtime/Composer?, kotlin/Int): dev.dtrentin.chart.model/ChartTheme // dev.dtrentin.chart.model/rememberMaterialChartTheme|rememberMaterialChartTheme(androidx.compose.runtime.Composer?;kotlin.Int){}[0]
final fun dev.dtrentin.chart.render/dev_dtrentin_chart_render_AxisRenderer$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.render/dev_dtrentin_chart_render_AxisRenderer$stableprop_getter|dev_dtrentin_chart_render_AxisRenderer$stableprop_getter(){}[0] final fun dev.dtrentin.chart.render/dev_dtrentin_chart_render_DateTimeAxisFormatter$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.render/dev_dtrentin_chart_render_DateTimeAxisFormatter$stableprop_getter|dev_dtrentin_chart_render_DateTimeAxisFormatter$stableprop_getter(){}[0]
final fun dev.dtrentin.chart.render/dev_dtrentin_chart_render_NumberFormat$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.render/dev_dtrentin_chart_render_NumberFormat$stableprop_getter|dev_dtrentin_chart_render_NumberFormat$stableprop_getter(){}[0] final fun dev.dtrentin.chart.render/dev_dtrentin_chart_render_DecimalAxisFormatter$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.render/dev_dtrentin_chart_render_DecimalAxisFormatter$stableprop_getter|dev_dtrentin_chart_render_DecimalAxisFormatter$stableprop_getter(){}[0]
final fun dev.dtrentin.chart.render/dev_dtrentin_chart_render_SignalRenderer$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.render/dev_dtrentin_chart_render_SignalRenderer$stableprop_getter|dev_dtrentin_chart_render_SignalRenderer$stableprop_getter(){}[0] final fun dev.dtrentin.chart.render/dev_dtrentin_chart_render_LineSignalRenderer$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.render/dev_dtrentin_chart_render_LineSignalRenderer$stableprop_getter|dev_dtrentin_chart_render_LineSignalRenderer$stableprop_getter(){}[0]
final fun dev.dtrentin.chart/RealtimeChart(dev.dtrentin.chart/RealtimeChartState, androidx.compose.ui/Modifier?, kotlin/Float, dev.dtrentin.chart.model/ChartTheme?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // dev.dtrentin.chart/RealtimeChart|RealtimeChart(dev.dtrentin.chart.RealtimeChartState;androidx.compose.ui.Modifier?;kotlin.Float;dev.dtrentin.chart.model.ChartTheme?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] final fun dev.dtrentin.chart.render/dev_dtrentin_chart_render_TimeAxisFormatter$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.render/dev_dtrentin_chart_render_TimeAxisFormatter$stableprop_getter|dev_dtrentin_chart_render_TimeAxisFormatter$stableprop_getter(){}[0]
final fun dev.dtrentin.chart.render/dev_dtrentin_chart_render_UnitAxisFormatter$stableprop_getter(): kotlin/Int // dev.dtrentin.chart.render/dev_dtrentin_chart_render_UnitAxisFormatter$stableprop_getter|dev_dtrentin_chart_render_UnitAxisFormatter$stableprop_getter(){}[0]
final fun dev.dtrentin.chart/RealtimeChart(dev.dtrentin.chart/RealtimeChartState, androidx.compose.ui/Modifier?, kotlin/Float, dev.dtrentin.chart.model/ChartTheme?, dev.dtrentin.chart.interaction/ChartInteractionState?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // dev.dtrentin.chart/RealtimeChart|RealtimeChart(dev.dtrentin.chart.RealtimeChartState;androidx.compose.ui.Modifier?;kotlin.Float;dev.dtrentin.chart.model.ChartTheme?;dev.dtrentin.chart.interaction.ChartInteractionState?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
final fun dev.dtrentin.chart/dev_dtrentin_chart_RealtimeChartState$stableprop_getter(): kotlin/Int // dev.dtrentin.chart/dev_dtrentin_chart_RealtimeChartState$stableprop_getter|dev_dtrentin_chart_RealtimeChartState$stableprop_getter(){}[0] final fun dev.dtrentin.chart/dev_dtrentin_chart_RealtimeChartState$stableprop_getter(): kotlin/Int // dev.dtrentin.chart/dev_dtrentin_chart_RealtimeChartState$stableprop_getter|dev_dtrentin_chart_RealtimeChartState$stableprop_getter(){}[0]
final fun dev.dtrentin.chart/dev_dtrentin_chart_SignalEntry$stableprop_getter(): kotlin/Int // dev.dtrentin.chart/dev_dtrentin_chart_SignalEntry$stableprop_getter|dev_dtrentin_chart_SignalEntry$stableprop_getter(){}[0]

View file

@ -11,6 +11,11 @@ plugins {
group = "dev.dtrentin" group = "dev.dtrentin"
version = "0.5.0-SNAPSHOT" version = "0.5.0-SNAPSHOT"
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler_reports")
metricsDestination = layout.buildDirectory.dir("compose_compiler_metrics")
}
kotlin { kotlin {
applyDefaultHierarchyTemplate() applyDefaultHierarchyTemplate()
explicitApi() explicitApi()
@ -28,7 +33,6 @@ kotlin {
val xcf = XCFramework("ChartRealtime") val xcf = XCFramework("ChartRealtime")
listOf( listOf(
iosX64(),
iosArm64(), iosArm64(),
iosSimulatorArm64() iosSimulatorArm64()
).forEach { target -> ).forEach { target ->
@ -45,7 +49,6 @@ kotlin {
implementation(compose.foundation) implementation(compose.foundation)
implementation(compose.material3) implementation(compose.material3)
implementation(libs.coroutines.core) implementation(libs.coroutines.core)
implementation(libs.kotlinx.datetime)
} }
androidMain.dependencies { androidMain.dependencies {
implementation(libs.androidx.core) implementation(libs.androidx.core)

View file

@ -2,86 +2,193 @@ package dev.dtrentin.chart
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.dtrentin.chart.buffer.LodDecimator
import dev.dtrentin.chart.buffer.TieredBuffer import dev.dtrentin.chart.buffer.TieredBuffer
import dev.dtrentin.chart.interaction.ChartInteractionState
import dev.dtrentin.chart.interaction.CrosshairState
import dev.dtrentin.chart.interaction.InverseProjection
import dev.dtrentin.chart.interaction.SignalValueAt
import dev.dtrentin.chart.interaction.ViewportMode
import dev.dtrentin.chart.model.AxisLabelMode import dev.dtrentin.chart.model.AxisLabelMode
import dev.dtrentin.chart.model.ChartTheme import dev.dtrentin.chart.model.ChartTheme
import dev.dtrentin.chart.render.AxisRenderer.drawXAxis import dev.dtrentin.chart.render.AxisRenderer.drawXAxis
import dev.dtrentin.chart.render.AxisRenderer.drawYAxis import dev.dtrentin.chart.render.AxisRenderer.drawYAxis
import dev.dtrentin.chart.render.AxisRenderer.resolveYRange import dev.dtrentin.chart.render.AxisRenderer.resolveYRange
import dev.dtrentin.chart.render.SignalRenderer.drawSignal
/** /**
* Renders all signals held by [state] on a Canvas. Recomposition is driven by Compose * Renders all signals held by [state] on a Canvas. Recomposition is driven by Compose
* snapshot observation of `state.dataVersion`, so the Canvas redraws only when new data * snapshot observation of `state.dataVersion`, so the Canvas redraws only when new data
* arrives (batched per frame by the Compose snapshot system). * arrives (batched per frame by the Compose snapshot system).
* *
* v0.5.0 wiring:
* - Decimation strategy from `state.config.render.lodStrategy` (default MinMaxLTTB).
* - Per-signal renderer from `signal.renderer` (default LineSignalRenderer singleton).
* - Decimation runs OUTSIDE the renderer and produces pre-projected `(lodX, lodY, count)`
* passed by primitive to `SignalRenderer.drawSignal`.
* - Optional [interaction] enables pinch-zoom / drag-pan / tap-crosshair. When null,
* chart behaves identically to v0.4.0 (read-only).
*
* @param state holds all signal data and config. * @param state holds all signal data and config.
* @param modifier applied to Canvas. * @param modifier applied to Canvas.
* @param xWindowSeconds visible X window in seconds; overrides [state.config.xWindowSeconds] at call site. * @param xWindowSeconds visible X window in seconds; overrides `state.config.data.xWindowSeconds` at call site.
* @param theme visual theme; overrides [state.config.theme] at call site. * @param theme visual theme; overrides `state.config.render.theme` at call site.
* @param interaction optional state holder enabling user gestures. Create via
* [dev.dtrentin.chart.interaction.rememberChartInteractionState].
*/ */
@Composable @Composable
public fun RealtimeChart( public fun RealtimeChart(
state: RealtimeChartState, state: RealtimeChartState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
xWindowSeconds: Float = state.config.xWindowSeconds, xWindowSeconds: Float = state.config.data.xWindowSeconds,
theme: ChartTheme = state.config.theme, theme: ChartTheme = state.config.render.theme,
interaction: ChartInteractionState? = null,
) { ) {
val config = state.config val config = state.config
val lodStrategy = config.render.lodStrategy
val textMeasurer = rememberTextMeasurer() val textMeasurer = rememberTextMeasurer()
val density = LocalDensity.current val density = LocalDensity.current
val chartLeftPx = remember(config.yLabelMode) { val chartLeftPx = remember(config.axis.yLabelMode) {
if (config.yLabelMode == AxisLabelMode.BESIDE) with(density) { 52.dp.toPx() } else 0f if (config.axis.yLabelMode == AxisLabelMode.BESIDE) with(density) { 52.dp.toPx() } else 0f
} }
val chartBottomInsetPx = remember(config.xLabelMode) { val chartBottomInsetPx = remember(config.axis.xLabelMode) {
if (config.xLabelMode == AxisLabelMode.BESIDE) with(density) { 20.dp.toPx() } else 0f if (config.axis.xLabelMode == AxisLabelMode.BESIDE) with(density) { 20.dp.toPx() } else 0f
} }
// Plain mutable Long slot — written inside draw lambda. Not a Compose state, so the // Plain mutable Long slot — written inside draw lambda. Not a Compose state, so the
// write does NOT invalidate the composition (avoids self-recomposition loop). // write does NOT invalidate the composition (avoids self-recomposition loop).
val lastRenderedVersion = remember { longArrayOf(-1L) } val lastRenderedVersion = remember { longArrayOf(-1L) }
val lodX = remember { FloatArray(TieredBuffer.TOTAL_CAPACITY) } val lodX = remember { FloatArray(TieredBuffer.TOTAL_CAPACITY) }
val lodY = remember { FloatArray(TieredBuffer.TOTAL_CAPACITY) } val lodY = remember { FloatArray(TieredBuffer.TOTAL_CAPACITY) }
val path = remember { Path() } val path = remember { Path() }
val lodDecimator = remember { LodDecimator() } // T9: zero-alloc Y-range out param. Layout: [0] = yMin, [1] = yMax.
val yRangeOut = remember { FloatArray(2) }
Canvas(modifier = modifier.background(theme.backgroundColor)) { // Cross-frame caches read by pointer-input lambdas. Plain LongArray slots (NOT Compose
// state) — writes inside draw must NOT invalidate composition. Pointer-input lambdas
// read the most recently-rendered values (1-frame lag is acceptable for gestures).
// Layout: [0] = latestMs, [1] = windowStartMs, [2] = windowMs.
val interactionCache = remember { longArrayOf(Long.MIN_VALUE, 0L, 0L) }
val baseModifier = modifier.background(theme.backgroundColor)
val gestureModifier = if (interaction != null) {
baseModifier
.pointerInput(interaction) {
detectTransformGestures { _, _, zoom, _ ->
if (zoom != 1f) interaction.applyZoom(zoom, fallbackXWindowSeconds = xWindowSeconds)
}
}
.pointerInput(interaction) {
detectDragGestures { change, dragAmount ->
val winMs = interactionCache[2]
val latestMs = interactionCache[0]
if (winMs <= 0L || latestMs == Long.MIN_VALUE) return@detectDragGestures
val chartW = (size.width.toFloat() - chartLeftPx).coerceAtLeast(1f)
// Drag right (positive dragAmount.x) → look earlier → negative delta.
val deltaMs = -((dragAmount.x / chartW) * winMs).toLong()
interaction.applyPan(deltaMs, latestMs)
change.consume()
}
}
.pointerInput(interaction) {
detectTapGestures { offset ->
if (interaction.crosshair != null) {
interaction.toggleCrosshair(null)
return@detectTapGestures
}
val winMs = interactionCache[2]
val winStartMs = interactionCache[1]
if (winMs <= 0L) return@detectTapGestures
val signals = state.signals
if (signals.isEmpty()) return@detectTapGestures
val tsAtPixel = InverseProjection.pixelXToTimestampMs(
pixelX = offset.x,
chartLeft = chartLeftPx,
chartRight = size.width.toFloat(),
windowStartMs = winStartMs,
windowMs = winMs,
)
// Build list once; avoid per-frame Map alloc.
val values = ArrayList<SignalValueAt>(signals.size)
for ((name, entry) in signals) {
if (!entry.config.visible) continue
val v = InverseProjection.nearestSampleValue(entry, tsAtPixel)
values.add(SignalValueAt(name, v))
}
interaction.toggleCrosshair(
CrosshairState(
pixelX = offset.x,
timestampMs = tsAtPixel,
signalValues = values,
)
)
}
}
} else baseModifier
Canvas(modifier = gestureModifier) {
val currentVersion = state.dataVersion val currentVersion = state.dataVersion
if (currentVersion == lastRenderedVersion[0]) return@Canvas // Recompose may run when interaction state (crosshair / mode) changes even if
val signals = state.signals // dataVersion did not — so still draw when interaction is non-null and crosshair
// is active (to keep overlay glued to canvas across resize / scroll).
val interactionActive = interaction != null &&
(interaction.crosshair != null || interaction.mode !is ViewportMode.Following || interaction.xWindowSecondsOverride > 0f)
if (currentVersion == lastRenderedVersion[0] && !interactionActive) return@Canvas
// T9: cached entry array — zero-alloc iteration in steady-state.
val signalsArr = state.signalsArray
val t0 = state.resolvedT0Ms ?: return@Canvas val t0 = state.resolvedT0Ms ?: return@Canvas
if (signals.isEmpty()) return@Canvas if (signalsArr.isEmpty()) return@Canvas
val windowMs = (xWindowSeconds * 1000f).toLong()
val effectiveXWindowSec =
if (interaction != null && interaction.xWindowSecondsOverride > 0f) interaction.xWindowSecondsOverride
else xWindowSeconds
val windowMs = (effectiveXWindowSec * 1000f).toLong()
if (windowMs <= 0L) return@Canvas if (windowMs <= 0L) return@Canvas
val chartBottom = size.height - chartBottomInsetPx val chartBottom = size.height - chartBottomInsetPx
val chartW = size.width - chartLeftPx
val pixelWidth = chartW.toInt().coerceAtLeast(1)
var latestMs = Long.MIN_VALUE var latestMs = Long.MIN_VALUE
for ((_, entry) in signals) { for (i in signalsArr.indices) {
val ts = entry.buffer.latestTimestampMs() val ts = signalsArr[i].buffer.latestTimestampMs()
if (ts > latestMs) latestMs = ts if (ts > latestMs) latestMs = ts
} }
if (latestMs == Long.MIN_VALUE) return@Canvas if (latestMs == Long.MIN_VALUE) return@Canvas
val windowStartMs = latestMs - windowMs
// Apply interaction viewport offset (History mode shifts window back from live edge).
val viewportOffsetMs = interaction?.viewportOffsetMs ?: 0L
val viewportRightMs = latestMs + viewportOffsetMs
val windowStartMs = viewportRightMs - windowMs
// Publish to pointer-input cache for next-frame gesture handlers.
interactionCache[0] = latestMs
interactionCache[1] = windowStartMs
interactionCache[2] = windowMs
// Single snapshot pass per signal (T11). Per-signal scratch arrays live in SignalEntry. // Single snapshot pass per signal (T11). Per-signal scratch arrays live in SignalEntry.
// Y-range scan and path generation both read the same snapshot — no double-snapshot. // Y-range scan and path generation both read the same snapshot — no double-snapshot.
var dataMin = 0f var dataMin = 0f
var dataMax = 0f var dataMax = 0f
var hasData = false var hasData = false
for ((_, entry) in signals) { for (i in signalsArr.indices) {
val entry = signalsArr[i]
if (!entry.config.visible) { entry.scratchCount = 0; continue } if (!entry.config.visible) { entry.scratchCount = 0; continue }
val n = entry.buffer.snapshot(windowStartMs, windowMs, entry.scratchTs, entry.scratchV) val n = entry.buffer.snapshot(windowStartMs, windowMs, entry.scratchTs, entry.scratchV)
entry.scratchCount = n entry.scratchCount = n
for (i in 0 until n) { for (j in 0 until n) {
val v = entry.scratchV[i] val v = entry.scratchV[j]
if (!hasData) { dataMin = v; dataMax = v; hasData = true } if (!hasData) { dataMin = v; dataMax = v; hasData = true }
else { else {
if (v < dataMin) dataMin = v if (v < dataMin) dataMin = v
@ -90,21 +197,103 @@ public fun RealtimeChart(
} }
} }
if (!hasData) { dataMin = -1f; dataMax = 1f } if (!hasData) { dataMin = -1f; dataMax = 1f }
val (yMin, yMax) = resolveYRange(config, dataMin, dataMax) resolveYRange(config, dataMin, dataMax, yRangeOut)
val yMin = yRangeOut[0]
val yMax = yRangeOut[1]
drawXAxis(windowStartMs, windowMs, theme, textMeasurer, config.xLabelMode, chartLeftPx, chartBottom, t0, showGrid = config.showGrid) drawXAxis(windowStartMs, windowMs, theme, textMeasurer, config.axis.xLabelMode, chartLeftPx, chartBottom, t0, showGrid = config.axis.showGrid)
drawYAxis(yMin, yMax, theme, textMeasurer, config.yLabelMode, config.yLabelDecimals, chartLeftPx, chartBottom, showGrid = config.showGrid) drawYAxis(yMin, yMax, theme, textMeasurer, config.axis.yLabelMode, config.axis.yLabelDecimals, chartLeftPx, chartBottom, showGrid = config.axis.showGrid)
for ((_, entry) in signals) { for (i in signalsArr.indices) {
drawSignal( val entry = signalsArr[i]
entry = entry, count = entry.scratchCount, // Decimate via configured strategy (outside renderer).
lodX = lodX, lodY = lodY, path = path, val pairCount = lodStrategy.decimate(
windowStartMs = windowStartMs, windowMs = windowMs, timestamps = entry.scratchTs,
yMin = yMin, yMax = yMax, values = entry.scratchV,
chartLeft = chartLeftPx, chartBottom = chartBottom, count = entry.scratchCount,
lodDecimator = lodDecimator, mode = config.lodMode, windowStartMs = windowStartMs,
windowMs = windowMs,
pixelWidth = pixelWidth,
outX = lodX,
outY = lodY,
)
// Delegate to per-signal renderer with primitive params.
with(entry.config.renderer) {
drawSignal(
color = entry.config.color,
strokeWidth = entry.config.strokeWidth,
visible = entry.config.visible,
lodX = lodX,
lodY = lodY,
count = pairCount,
path = path,
chartLeft = chartLeftPx,
chartRight = size.width,
chartBottom = chartBottom,
yMin = yMin,
yMax = yMax,
)
}
}
// Crosshair overlay (drawn last → above signals + axes).
val crosshair = interaction?.crosshair
if (crosshair != null) {
drawCrosshair(
crosshair = crosshair,
state = state,
theme = theme,
textMeasurer = textMeasurer,
chartLeft = chartLeftPx,
chartRight = size.width,
chartBottom = chartBottom,
yMin = yMin,
yMax = yMax,
) )
} }
lastRenderedVersion[0] = currentVersion lastRenderedVersion[0] = currentVersion
} }
} }
/**
* Draw vertical guide line at `crosshair.pixelX`, per-signal dot markers at the inverse-
* projected timestamp, and a top-right readout box with timestamp + per-signal values.
*
* Stateless / zero-buffer-access reads `entry.scratch*` already populated by the
* surrounding draw pass + per-signal values already resolved in [CrosshairState.signalValues].
*/
private fun DrawScope.drawCrosshair(
crosshair: CrosshairState,
state: RealtimeChartState,
theme: ChartTheme,
textMeasurer: TextMeasurer,
chartLeft: Float,
chartRight: Float,
chartBottom: Float,
yMin: Float,
yMax: Float,
) {
val px = crosshair.pixelX.coerceIn(chartLeft, chartRight)
// Vertical guide
drawLine(
color = theme.axisColor,
start = Offset(px, 0f),
end = Offset(px, chartBottom),
strokeWidth = theme.strokeWidth,
)
val yRange = (yMax - yMin).coerceAtLeast(1e-6f)
val invY = 1f / yRange
// Per-signal dots
val signals = state.signals
for (sv in crosshair.signalValues) {
if (sv.value.isNaN()) continue
val entry = signals[sv.signalName] ?: continue
val yPx = chartBottom - ((sv.value - yMin) * invY) * chartBottom
drawCircle(
color = entry.config.color,
radius = 4f,
center = Offset(px, yPx),
)
}
}

View file

@ -1,5 +1,6 @@
package dev.dtrentin.chart package dev.dtrentin.chart
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.runtime.snapshots.SnapshotApplyConflictException import androidx.compose.runtime.snapshots.SnapshotApplyConflictException
@ -13,7 +14,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.Clock import kotlin.time.Clock
/** /**
* Holds all state for a [RealtimeChart]. Create once, pass to composable. * Holds all state for a [RealtimeChart]. Create once, pass to composable.
@ -31,6 +32,7 @@ import kotlinx.datetime.Clock
* - Timestamps per signal must be monotonic non-decreasing. Backward samples are silently dropped. * - Timestamps per signal must be monotonic non-decreasing. Backward samples are silently dropped.
* - Samples for unknown signal names are silently dropped (call [addSignal] first). * - Samples for unknown signal names are silently dropped (call [addSignal] first).
*/ */
@Stable
public class RealtimeChartState( public class RealtimeChartState(
public val config: ChartConfig = ChartConfig(), public val config: ChartConfig = ChartConfig(),
) { ) {
@ -39,13 +41,31 @@ public class RealtimeChartState(
@Volatile private var _signals: Map<String, SignalEntry> = emptyMap() @Volatile private var _signals: Map<String, SignalEntry> = emptyMap()
internal val signals: Map<String, SignalEntry> get() = _signals internal val signals: Map<String, SignalEntry> get() = _signals
// T9: cached Array<SignalEntry> snapshot of _signals.values. Read by render loop for
// zero-alloc indexed iteration (vs. Map.entries iterator + Map.Entry per iteration).
// Invalidated only by addSignal/removeSignal — push() does NOT invalidate, since
// SignalEntry identity is stable across pushes (buffer/scratch live inside the entry).
// Rebuild is lazy on next read. Steady-state (no add/remove) = 0 alloc.
@Volatile private var _signalsArrayCache: Array<SignalEntry>? = null
@Suppress("UNCHECKED_CAST")
internal val signalsArray: Array<SignalEntry>
get() {
val cached = _signalsArrayCache
val cur = _signals
if (cached != null && cached.size == cur.size) return cached
val fresh = cur.values.toTypedArray<SignalEntry>()
_signalsArrayCache = fresh
return fresh
}
// Compose-observable counter. Reads inside composition register snapshot dependencies, // Compose-observable counter. Reads inside composition register snapshot dependencies,
// writes from background threads are routed through Snapshot.withMutableSnapshot. // writes from background threads are routed through Snapshot.withMutableSnapshot.
private val _dataVersion = mutableLongStateOf(0L) private val _dataVersion = mutableLongStateOf(0L)
/** Increments on every [push] / [addSignal] / [removeSignal]. Use to skip rendering when unchanged. */ /** Increments on every [push] / [addSignal] / [removeSignal]. Use to skip rendering when unchanged. */
internal val dataVersion: Long get() = _dataVersion.longValue internal val dataVersion: Long get() = _dataVersion.longValue
@Volatile internal var resolvedT0Ms: Long? = when (val t = config.t0) { @Volatile internal var resolvedT0Ms: Long? = when (val t = config.data.t0) {
is T0.Fixed -> t.epochMs is T0.Fixed -> t.epochMs
T0.FirstSample -> null T0.FirstSample -> null
} }
@ -53,12 +73,14 @@ public class RealtimeChartState(
/** Registers a signal. Must call before [push]. If [name] exists, buffer is replaced. */ /** Registers a signal. Must call before [push]. If [name] exists, buffer is replaced. */
public fun addSignal(name: String, signalConfig: SignalConfig) { public fun addSignal(name: String, signalConfig: SignalConfig) {
_signals = _signals + (name to SignalEntry(signalConfig, TieredBuffer())) _signals = _signals + (name to SignalEntry(signalConfig, TieredBuffer()))
_signalsArrayCache = null
applySnapshot { _dataVersion.longValue++ } applySnapshot { _dataVersion.longValue++ }
} }
/** Removes signal [name] and its buffer. No-op if absent. */ /** Removes signal [name] and its buffer. No-op if absent. */
public fun removeSignal(name: String) { public fun removeSignal(name: String) {
_signals = _signals - name _signals = _signals - name
_signalsArrayCache = null
applySnapshot { _dataVersion.longValue++ } applySnapshot { _dataVersion.longValue++ }
} }
@ -79,6 +101,23 @@ public class RealtimeChartState(
applySnapshot { _dataVersion.longValue++ } applySnapshot { _dataVersion.longValue++ }
} }
/**
* Purges all signal buffers, resets dataVersion to 0, resets resolvedT0Ms per `config.data.t0`.
* Signal registrations are preserved (use [removeSignal] to drop a signal entirely).
* Thread-safe.
*/
public fun clear() {
for (entry in _signals.values) {
entry.buffer.clear()
entry.lastPushedTs = Long.MIN_VALUE
}
resolvedT0Ms = when (val t = config.data.t0) {
is T0.Fixed -> t.epochMs
T0.FirstSample -> null
}
applySnapshot { _dataVersion.longValue = 0L }
}
/** Collects (timestampMs, value) pairs from [flow] into signal [name]. Returns [Job]. */ /** Collects (timestampMs, value) pairs from [flow] into signal [name]. Returns [Job]. */
@JvmName("collectFromTimestamped") @JvmName("collectFromTimestamped")
public fun collectFrom(name: String, flow: Flow<Pair<Long, Float>>, scope: CoroutineScope): Job = public fun collectFrom(name: String, flow: Flow<Pair<Long, Float>>, scope: CoroutineScope): Job =

View file

@ -55,3 +55,27 @@ internal class CircularBuffer(val capacity: Int) {
/** Test-only: seed writeIndex to simulate long-running sessions past Int.MAX_VALUE. */ /** Test-only: seed writeIndex to simulate long-running sessions past Int.MAX_VALUE. */
internal fun setWriteIndexForTest(idx: Long) { writeIndex = idx } internal fun setWriteIndexForTest(idx: Long) { writeIndex = idx }
} }
/**
* Bisect on a pre-snapshotted chronologically-sorted timestamp array.
* Returns the first index `i` in `[0, count)` where `ts[i] >= targetMs`,
* or `count` if no such index exists (all timestamps strictly less than target).
*
* Caller contract:
* - `ts` must be sorted non-decreasing in `[0, count)` (oldest at 0, newest at count-1)
* - `count` must be `<= ts.size`
* - `count == 0` returns 0
*
* O(log count). Zero-alloc. Used by `TieredBuffer.snapshotWindow` to locate the
* window-start index in each tier's snapshot instead of linear-scanning all n samples.
*/
internal fun bisectStart(ts: LongArray, count: Int, targetMs: Long): Int {
if (count <= 0) return 0
var lo = 0
var hi = count
while (lo < hi) {
val mid = (lo + hi) ushr 1
if (ts[mid] < targetMs) lo = mid + 1 else hi = mid
}
return lo
}

View file

@ -199,6 +199,79 @@ internal class TieredBuffer {
return outIdx return outIdx
} }
/**
* Bisect-based variant of [snapshot]. Returns identical content + ordering for the
* same (windowStartMs, windowMs) args, but locates the window-start index in each
* tier's chronologically-sorted snapshot via O(log n) bisect instead of an
* O(n) linear pre-scan. Linear walk runs only over the in-window subrange.
*
* Output ordering: tier2 oldest first, then tier1, then tier0 newest same as [snapshot].
* Tier boundary clamps (tier0BoundaryMs / tier1BoundaryMs) preserved verbatim.
*
* Returns 0 on empty buffer or window entirely outside data.
*/
fun snapshotWindow(
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)
// Tier2 records satisfy ts < tier1BoundaryMs (older than tier1 horizon),
// so upper clamp is min(windowEndMs, tier1BoundaryMs).
val tier2UpperExclusive = if (windowEndMs < tier1BoundaryMs) windowEndMs else tier1BoundaryMs
val start = bisectStart(t2Ts, n2, windowStartMs)
var i = start
while (i < n2) {
val ts = t2Ts[i]
if (ts >= tier2UpperExclusive) break
if (outIdx >= outTimestamps.size) return outIdx
outTimestamps[outIdx] = ts; outValues[outIdx] = t2Vs[i]; outIdx++
i++
}
}
if (windowStartMs < tier0BoundaryMs && windowEndMs > tier1BoundaryMs) {
val n1 = tier1.snapshot(t1Ts, t1Vs)
// Tier1 records satisfy tier1BoundaryMs <= ts < tier0BoundaryMs.
val tier1Lower = if (windowStartMs > tier1BoundaryMs) windowStartMs else tier1BoundaryMs
val tier1UpperExclusive = if (windowEndMs < tier0BoundaryMs) windowEndMs else tier0BoundaryMs
val start = bisectStart(t1Ts, n1, tier1Lower)
var i = start
while (i < n1) {
val ts = t1Ts[i]
if (ts >= tier1UpperExclusive) break
if (outIdx >= outTimestamps.size) return outIdx
outTimestamps[outIdx] = ts; outValues[outIdx] = t1Vs[i]; outIdx++
i++
}
}
val tier0Start = if (windowStartMs > tier0BoundaryMs) windowStartMs else tier0BoundaryMs
val n0 = tier0.snapshot(t0Ts, t0Vs)
val start0 = bisectStart(t0Ts, n0, tier0Start)
var i = start0
while (i < n0) {
val ts = t0Ts[i]
if (ts >= windowEndMs) break
if (outIdx >= outTimestamps.size) return outIdx
outTimestamps[outIdx] = ts; outValues[outIdx] = t0Vs[i]; outIdx++
i++
}
return outIdx
}
fun clear() { fun clear() {
tier0.clear(); tier1.clear(); tier2.clear() tier0.clear(); tier1.clear(); tier2.clear()
tier1BinStartMs = -1L tier1BinStartMs = -1L

View file

@ -0,0 +1,104 @@
package dev.dtrentin.chart.interaction
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
/**
* Hoistable state for chart interaction (zoom / pan / crosshair tap).
*
* Create via [rememberChartInteractionState]. Pass to
* [dev.dtrentin.chart.RealtimeChart] as the `interaction` parameter (opt-in; default null
* preserves the v0.4.0 read-only behaviour).
*
* Mutations route through Compose-observable backing state so the chart canvas
* recomposes on viewport / crosshair changes.
*
* Decision log:
* - Pan transitions to [ViewportMode.History] (carries anchor in epoch ms) rather than
* plain [ViewportMode.Frozen]; History conveys "paused at this absolute time" semantics
* needed by render. The plain Frozen state remains in the API surface for future
* pause-without-anchor flows (not used by the default pan path).
*/
@Stable
public class ChartInteractionState internal constructor(
public val config: InteractionConfig,
) {
private val _xWindowSeconds = mutableFloatStateOf(0f) // 0 = use composable's xWindowSeconds
/** Effective xWindowSeconds override, or 0f when no zoom applied (use caller's value). */
public val xWindowSecondsOverride: Float get() = _xWindowSeconds.floatValue
private val _mode = mutableStateOf<ViewportMode>(ViewportMode.Following)
public val mode: ViewportMode get() = _mode.value
private val _viewportOffsetMs = mutableLongStateOf(0L)
/** Offset from live edge in ms; negative = looking back. Zero in [ViewportMode.Following]. */
public val viewportOffsetMs: Long get() = _viewportOffsetMs.longValue
private val _crosshair = mutableStateOf<CrosshairState?>(null)
public val crosshair: CrosshairState? get() = _crosshair.value
/**
* Apply pinch zoom. [scale] > 1 zooms in (narrower window); < 1 zooms out.
* [fallbackXWindowSeconds] is used when no prior zoom override exists (first pinch).
*/
internal fun applyZoom(scale: Float, fallbackXWindowSeconds: Float) {
if (!config.zoomEnabled) return
if (scale <= 0f || !scale.isFinite()) return
val current = if (_xWindowSeconds.floatValue > 0f) _xWindowSeconds.floatValue else fallbackXWindowSeconds
if (current <= 0f) return
val next = (current / scale).coerceIn(config.minXWindowSeconds, config.maxXWindowSeconds)
_xWindowSeconds.floatValue = next
}
/**
* Shift viewport by [deltaMs]. Negative delta = look earlier (drag right past).
* Switches mode to [ViewportMode.History] anchored at `latestTsMs + offset`. When the
* cumulative offset reaches the live edge (within [InteractionConfig.resumeFollowingThresholdMs]),
* mode resumes to [ViewportMode.Following] and offset resets to 0.
*/
internal fun applyPan(deltaMs: Long, latestTsMs: Long) {
if (!config.panEnabled) return
val next = _viewportOffsetMs.longValue + deltaMs
if (next >= -config.resumeFollowingThresholdMs) {
_viewportOffsetMs.longValue = 0L
_mode.value = ViewportMode.Following
return
}
_viewportOffsetMs.longValue = next
_mode.value = ViewportMode.History(latestTsMs + next)
}
/**
* Toggle crosshair. If a crosshair is currently active, dismiss it (regardless of
* [newState]); otherwise install [newState]. When [InteractionConfig.tapCrosshairEnabled]
* is false, crosshair is always cleared.
*/
internal fun toggleCrosshair(newState: CrosshairState?) {
if (!config.tapCrosshairEnabled) {
_crosshair.value = null
return
}
_crosshair.value = if (_crosshair.value != null) null else newState
}
/** Reset to Following mode with zero offset. Does not touch zoom override. */
internal fun resumeFollowing() {
_mode.value = ViewportMode.Following
_viewportOffsetMs.longValue = 0L
}
}
/**
* Create + remember a [ChartInteractionState]. Pass to `RealtimeChart(interaction = ...)`.
*
* @param config gesture enable flags and clamps. Changing [config] across recompositions
* instantiates a new state (current zoom / pan / crosshair are dropped).
*/
@Composable
public fun rememberChartInteractionState(
config: InteractionConfig = InteractionConfig(),
): ChartInteractionState = remember(config) { ChartInteractionState(config) }

View file

@ -0,0 +1,35 @@
package dev.dtrentin.chart.interaction
import androidx.compose.runtime.Immutable
/**
* Per-signal value sample at the crosshair timestamp.
*
* @param signalName name registered via `RealtimeChartState.addSignal`.
* @param value sample value at [CrosshairState.timestampMs]; may be `Float.NaN` when no
* sample exists within the signal's snapshot window for that timestamp.
*/
@Immutable
public data class SignalValueAt(
public val signalName: String,
public val value: Float,
)
/**
* Snapshot of the active crosshair. Created by [dev.dtrentin.chart.RealtimeChart] on tap.
*
* Design note (decision log): [signalValues] is a [List] of [SignalValueAt] rather than
* [Map] to avoid per-frame `Map<String, Float>` allocation on the render path and to keep
* Compose stability inference happy without boxing. The list is small ( active signals)
* and is consumed via index-based iteration in the chart canvas.
*
* @param pixelX X pixel offset relative to chart canvas (NOT chart area: includes left inset).
* @param timestampMs inverse-projected timestamp in epoch ms.
* @param signalValues per-signal value at [timestampMs].
*/
@Immutable
public data class CrosshairState(
public val pixelX: Float,
public val timestampMs: Long,
public val signalValues: List<SignalValueAt>,
)

View file

@ -0,0 +1,24 @@
package dev.dtrentin.chart.interaction
import androidx.compose.runtime.Immutable
/**
* Tunables for [ChartInteractionState]. Each gesture can be individually disabled.
*
* @param zoomEnabled enables pinch-to-zoom (mutates xWindowSeconds).
* @param panEnabled enables 1-finger horizontal drag (shifts viewport, switches to History).
* @param tapCrosshairEnabled enables single-tap crosshair toggle.
* @param minXWindowSeconds inclusive lower clamp for zoom.
* @param maxXWindowSeconds inclusive upper clamp for zoom.
* @param resumeFollowingThresholdMs if pan brings viewport within this many ms of the
* live edge (offsetMs >= -threshold), mode auto-resumes to [ViewportMode.Following].
*/
@Immutable
public data class InteractionConfig(
val zoomEnabled: Boolean = true,
val panEnabled: Boolean = true,
val tapCrosshairEnabled: Boolean = true,
val minXWindowSeconds: Float = 0.5f,
val maxXWindowSeconds: Float = 3600f,
val resumeFollowingThresholdMs: Long = 500L,
)

View file

@ -0,0 +1,87 @@
package dev.dtrentin.chart.interaction
import dev.dtrentin.chart.SignalEntry
import kotlin.math.abs
/**
* Internal helpers for pixel data inverse projection.
*
* Marked `internal` because [nearestSampleValue] accepts the `internal` [SignalEntry] type;
* public consumers do not construct [CrosshairState] themselves `RealtimeChart` builds it
* on tap and exposes it via [ChartInteractionState.crosshair].
*/
internal object InverseProjection {
/**
* Inverse-project a pixel X to an epoch-ms timestamp inside the current window.
*
* The pixel is clamped to `[chartLeft, chartRight]` before fractional projection, so
* taps outside the chart area resolve to the nearest edge timestamp instead of
* returning `null` or extrapolating past the window.
*/
fun pixelXToTimestampMs(
pixelX: Float,
chartLeft: Float,
chartRight: Float,
windowStartMs: Long,
windowMs: Long,
): Long {
val chartW = (chartRight - chartLeft).coerceAtLeast(1f)
val frac = ((pixelX - chartLeft) / chartW).coerceIn(0f, 1f)
return windowStartMs + (frac * windowMs).toLong()
}
/**
* Returns the value of the sample closest to [targetTsMs] in [entry]'s already-populated
* scratch arrays. Caller must have invoked `entry.buffer.snapshot(...)` for the current
* frame (i.e. `entry.scratchCount`, `entry.scratchTs`, `entry.scratchV` are populated).
*
* Uses binary search on the chronologically-sorted `scratchTs` (snapshot output ordering
* is documented in `TieredBuffer.snapshot`).
*
* Returns `Float.NaN` when the signal's snapshot is empty.
*/
fun nearestSampleValue(
entry: SignalEntry,
targetTsMs: Long,
): Float {
val n = entry.scratchCount
if (n == 0) return Float.NaN
val ts = entry.scratchTs
var lo = 0
var hi = n
while (lo < hi) {
val mid = (lo + hi) ushr 1
if (ts[mid] < targetTsMs) lo = mid + 1 else hi = mid
}
// lo = first index with ts[lo] >= target (or n).
val idxA = (lo - 1).coerceAtLeast(0)
val idxB = if (lo < n) lo else n - 1
val distA = abs(ts[idxA] - targetTsMs)
val distB = abs(ts[idxB] - targetTsMs)
val idx = if (distA <= distB) idxA else idxB
return entry.scratchV[idx]
}
/**
* Find sample's timestamp + value pair (alternative API for callers needing the actual
* sample ts, e.g. for "snap-to-sample" marker rendering). Returns `Long.MIN_VALUE` / `NaN`
* sentinel pair on empty buffer.
*
* Returns nearest index by absolute |ts - target|.
*/
fun nearestSampleIndex(entry: SignalEntry, targetTsMs: Long): Int {
val n = entry.scratchCount
if (n == 0) return -1
val ts = entry.scratchTs
var lo = 0
var hi = n
while (lo < hi) {
val mid = (lo + hi) ushr 1
if (ts[mid] < targetTsMs) lo = mid + 1 else hi = mid
}
val idxA = (lo - 1).coerceAtLeast(0)
val idxB = if (lo < n) lo else n - 1
return if (abs(ts[idxA] - targetTsMs) <= abs(ts[idxB] - targetTsMs)) idxA else idxB
}
}

View file

@ -0,0 +1,20 @@
package dev.dtrentin.chart.interaction
import androidx.compose.runtime.Immutable
/**
* Viewport playback mode for [ChartInteractionState].
*
* - [Following]: auto-scroll follows latest timestamp (default).
* - [Frozen]: viewport paused at current offset; reserved for explicit "pause" UX.
* Internal pan transitions produce [History] (carries anchor); this state is exposed
* for callers that want a pause-without-anchor semantic.
* - [History]: viewport pinned to [anchorMs] (absolute epoch ms of right edge).
*/
@Immutable
public sealed class ViewportMode {
public data object Following : ViewportMode()
public data object Frozen : ViewportMode()
@Immutable
public data class History(public val anchorMs: Long) : ViewportMode()
}

View file

@ -0,0 +1,46 @@
package dev.dtrentin.chart.lod
import androidx.compose.runtime.Immutable
/**
* Strategy for level-of-detail downsampling of timestamped sensor samples
* into per-pixel-column screen-space points for line rendering.
*
* Implementations must be zero-allocation at [decimate] time. All scratch
* buffers must be preallocated by the implementation constructor.
*
* Implementations are expected to:
* - Filter input samples to window `[windowStartMs, windowStartMs + windowMs]`.
* - Project surviving samples to integer pixel columns `0..pixelWidth-1`.
* - If the surviving count `n <= pixelWidth`, pass-through raw samples (x = col + 0.5, y = raw).
* - Otherwise, apply their decimation algorithm.
*
* @see MinMaxLodStrategy
* @see LttbLodStrategy
* @see MinMaxLttbLodStrategy
*/
@Immutable
public interface LodStrategy {
/**
* Decimates [count] samples (in chronological order) drawn from
* [timestamps]/[values], clipped to window `[windowStartMs .. windowStartMs + windowMs]`,
* into [pixelWidth] columns. Writes screen-space column positions into [outX]
* (range `0..pixelWidth` in column units, `+0.5` for column center) and the
* corresponding raw Y values into [outY].
*
* @return number of points written. May be 0 (no samples in window),
* `<= 2 * pixelWidth` (MinMax), or `<= pixelWidth` (LTTB / MinMaxLTTB).
* Caller-supplied [outX] / [outY] must have capacity at least `2 * pixelWidth`.
*/
public fun decimate(
timestamps: LongArray,
values: FloatArray,
count: Int,
windowStartMs: Long,
windowMs: Long,
pixelWidth: Int,
outX: FloatArray,
outY: FloatArray,
): Int
}

View file

@ -1,21 +1,33 @@
package dev.dtrentin.chart.buffer package dev.dtrentin.chart.lod
import dev.dtrentin.chart.model.LodMode import dev.dtrentin.chart.buffer.TieredBuffer
import kotlin.math.abs
internal class LodDecimator( /**
* Largest-Triangle-Three-Buckets per-pixel-column strategy.
*
* Buckets samples by pixel column; for each non-empty column (after the first) selects
* the sample maximizing the triangle area formed with the previous output point and the
* mean of the next non-empty column. Output count `<= pixelWidth`.
*
* Zero-allocation at [decimate] time: all scratch buffers sized at construction.
*
* @param maxCount upper bound on input sample count handled in a single call.
* Defaults to [TieredBuffer.TOTAL_CAPACITY].
* @param maxPixelWidth upper bound on `pixelWidth` argument. Defaults to 2048.
*/
public class LttbLodStrategy(
maxCount: Int = TieredBuffer.TOTAL_CAPACITY, maxCount: Int = TieredBuffer.TOTAL_CAPACITY,
maxPixelWidth: Int = 2048, maxPixelWidth: Int = 2048,
) { ) : LodStrategy {
private val colArr = IntArray(maxCount) private val colArr = IntArray(maxCount)
private val valArr = FloatArray(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 bucketSumY = DoubleArray(maxPixelWidth)
private val bucketCountArr = IntArray(maxPixelWidth) private val bucketCountArr = IntArray(maxPixelWidth)
private val nextNonEmpty = IntArray(maxPixelWidth + 1) private val nextNonEmpty = IntArray(maxPixelWidth + 1)
fun decimate( override fun decimate(
timestamps: LongArray, timestamps: LongArray,
values: FloatArray, values: FloatArray,
count: Int, count: Int,
@ -24,14 +36,13 @@ internal class LodDecimator(
pixelWidth: Int, pixelWidth: Int,
outX: FloatArray, outX: FloatArray,
outY: FloatArray, outY: FloatArray,
mode: LodMode = LodMode.MIN_MAX,
): Int { ): Int {
if (count == 0 || pixelWidth <= 0 || windowMs <= 0) return 0 if (count == 0 || pixelWidth <= 0 || windowMs <= 0) return 0
var n = 0 var n = 0
for (i in 0 until count) { for (i in 0 until count) {
val tRelMs = timestamps[i] - windowStartMs val tRelMs = timestamps[i] - windowStartMs
if (tRelMs < 0 || tRelMs > windowMs) continue if (tRelMs < 0 || tRelMs >= windowMs) continue
colArr[n] = (tRelMs.toFloat() / windowMs.toFloat() * pixelWidth).toInt().coerceIn(0, pixelWidth - 1) colArr[n] = (tRelMs.toFloat() / windowMs.toFloat() * pixelWidth).toInt().coerceIn(0, pixelWidth - 1)
valArr[n] = values[i] valArr[n] = values[i]
n++ n++
@ -43,31 +54,6 @@ internal class LodDecimator(
return n return n
} }
return when (mode) {
LodMode.MIN_MAX -> decimateMinMax(n, pixelWidth, outX, outY)
LodMode.LTTB -> decimateLttb(n, pixelWidth, outX, outY)
}
}
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 (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]]++ } for (i in 0 until n) { bucketSumY[colArr[i]] += valArr[i]; bucketCountArr[colArr[i]]++ }
@ -110,7 +96,7 @@ internal class LodDecimator(
var bestArea = -1f var bestArea = -1f
var bestVal = valArr[samplePtr] var bestVal = valArr[samplePtr]
for (i in samplePtr until bucketEnd) { for (i in samplePtr until bucketEnd) {
val area = kotlin.math.abs((prevX - nextAvgX) * (valArr[i] - prevY) - (prevX - colX) * (nextAvgY - prevY)) val area = abs((prevX - nextAvgX) * (valArr[i] - prevY) - (prevX - colX) * (nextAvgY - prevY))
if (area > bestArea) { bestArea = area; bestVal = valArr[i] } if (area > bestArea) { bestArea = area; bestVal = valArr[i] }
} }
outX[outIdx] = colX; outY[outIdx] = bestVal outX[outIdx] = colX; outY[outIdx] = bestVal

View file

@ -0,0 +1,72 @@
package dev.dtrentin.chart.lod
import dev.dtrentin.chart.buffer.TieredBuffer
/**
* Per-pixel-column min+max preservation strategy.
*
* For each non-empty pixel column emits two points: column-min Y and column-max Y
* (both at column center X). Preserves signal peaks even at extreme decimation ratios.
* Output count `<= 2 * pixelWidth`.
*
* Zero-allocation at [decimate] time: all scratch buffers sized at construction.
*
* @param maxCount upper bound on input sample count handled in a single call.
* Defaults to [TieredBuffer.TOTAL_CAPACITY].
* @param maxPixelWidth upper bound on `pixelWidth` argument. Defaults to 2048.
*/
public class MinMaxLodStrategy(
maxCount: Int = TieredBuffer.TOTAL_CAPACITY,
maxPixelWidth: Int = 2048,
) : LodStrategy {
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)
override fun decimate(
timestamps: LongArray,
values: FloatArray,
count: Int,
windowStartMs: Long,
windowMs: Long,
pixelWidth: Int,
outX: FloatArray,
outY: FloatArray,
): 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
}
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
}
}

View file

@ -0,0 +1,299 @@
package dev.dtrentin.chart.lod
import dev.dtrentin.chart.buffer.TieredBuffer
import kotlin.math.abs
/**
* MinMaxLTTB downsampling strategy (SOTA per Donckt et al. 2023, arXiv:2305.00332).
*
* Two-step pipeline:
* 1. **MinMax preselection**: split window-clipped samples into `ratio * pixelWidth / 2`
* equal-size buckets; from each bucket emit both the per-bucket argmin-Y and argmax-Y
* sample, preserving chronological order ( `ratio * pixelWidth` preselected points).
* 2. **LTTB**: run Largest-Triangle-Three-Buckets on the preselected set, producing
* at most `pixelWidth` output points.
*
* The preselection step guarantees per-bucket extrema survive, so spikes are preserved
* even at extreme decimation ratios unlike vanilla LTTB which can drop them. Output
* count `<= pixelWidth`.
*
* Zero-allocation at [decimate] time: all scratch buffers sized at construction.
*
* Reference: predict-idlab/tsdownsample (`MinMaxLTTBDownsampler`).
*
* @param ratio preselection multiplier. Final preselected count `ratio * pixelWidth`.
* Higher ratio better fidelity, more LTTB work. Default 4 (matches reference impl).
* @param maxCount upper bound on input sample count per call.
* Defaults to [TieredBuffer.TOTAL_CAPACITY].
* @param maxPixelWidth upper bound on `pixelWidth` argument. Defaults to 2048.
*/
public class MinMaxLttbLodStrategy(
private val ratio: Int = DEFAULT_RATIO,
maxCount: Int = TieredBuffer.TOTAL_CAPACITY,
maxPixelWidth: Int = 2048,
) : LodStrategy {
init {
require(ratio >= 1) { "ratio must be >= 1, got $ratio" }
require(maxCount > 0) { "maxCount must be > 0, got $maxCount" }
require(maxPixelWidth > 0) { "maxPixelWidth must be > 0, got $maxPixelWidth" }
}
// Window-clip + col-project scratch (per-sample).
private val colArr = IntArray(maxCount)
private val valArr = FloatArray(maxCount)
// Preselected scratch: ≤ ratio * pixelWidth points (col + val).
private val preCol = IntArray(ratio * maxPixelWidth)
private val preVal = FloatArray(ratio * maxPixelWidth)
override fun decimate(
timestamps: LongArray,
values: FloatArray,
count: Int,
windowStartMs: Long,
windowMs: Long,
pixelWidth: Int,
outX: FloatArray,
outY: FloatArray,
): Int {
if (count == 0 || pixelWidth <= 0 || windowMs <= 0) return 0
// Step 0: window clip + column projection.
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
// Pass-through when input fits.
if (n <= pixelWidth) {
for (i in 0 until n) { outX[i] = colArr[i] + 0.5f; outY[i] = valArr[i] }
return n
}
// Step 1: MinMax preselection.
// Target preselected count = ratio * pixelWidth, in (ratio * pixelWidth / 2) min+max buckets.
// Cap preselected buckets at n/2 so each bucket has ≥ 2 samples on average.
var nBucketsPre = (ratio * pixelWidth) / 2
if (nBucketsPre < 1) nBucketsPre = 1
if (nBucketsPre * 2 >= n) {
// Not enough samples per preselect bucket to be worthwhile → fall back to plain LTTB.
return lttbOnColumns(colArr, valArr, n, pixelWidth, outX, outY)
}
var preCount = 0
// Float bucket size, integer floor boundaries.
val bucketSizeF = n.toDouble() / nBucketsPre.toDouble()
for (b in 0 until nBucketsPre) {
val lo = (b * bucketSizeF).toInt()
var hi = ((b + 1) * bucketSizeF).toInt()
if (b == nBucketsPre - 1) hi = n
if (hi <= lo) continue
var iMin = lo
var iMax = lo
var vMin = valArr[lo]
var vMax = valArr[lo]
for (i in lo + 1 until hi) {
val v = valArr[i]
if (v < vMin) { vMin = v; iMin = i }
if (v > vMax) { vMax = v; iMax = i }
}
if (iMin == iMax) {
// Constant bucket — emit single point.
preCol[preCount] = colArr[iMin]; preVal[preCount] = vMin; preCount++
} else if (iMin < iMax) {
preCol[preCount] = colArr[iMin]; preVal[preCount] = vMin; preCount++
preCol[preCount] = colArr[iMax]; preVal[preCount] = vMax; preCount++
} else {
preCol[preCount] = colArr[iMax]; preVal[preCount] = vMax; preCount++
preCol[preCount] = colArr[iMin]; preVal[preCount] = vMin; preCount++
}
}
if (preCount == 0) return 0
// Step 2: LTTB on preselected (preCol, preVal, preCount → pixelWidth).
return lttbOnPreselected(preCount, pixelWidth, outX, outY)
}
/**
* Standard LTTB on the preselected (preCol, preVal) buffer.
* X coordinates are derived as `preCol[i] + 0.5f`.
*/
private fun lttbOnPreselected(
preCount: Int,
pixelWidth: Int,
outX: FloatArray,
outY: FloatArray,
): Int {
// Degenerate: preselected ≤ output budget → pass-through.
if (preCount <= pixelWidth) {
for (i in 0 until preCount) {
outX[i] = preCol[i] + 0.5f
outY[i] = preVal[i]
}
return preCount
}
// pixelWidth >= 2 guaranteed: if pixelWidth <= 1 we'd be in pass-through above
// (n <= pixelWidth triggers when pixelWidth >= n; preCount > pixelWidth here implies pixelWidth < preCount,
// and our early return at top requires pixelWidth > 0; handle 1 explicitly).
if (pixelWidth == 1) {
outX[0] = preCol[0] + 0.5f
outY[0] = preVal[0]
return 1
}
// Convention: bucket 0 = first point, bucket pixelWidth-1 = last point, middles decimated.
val nMiddleBuckets = pixelWidth - 2
val innerCount = preCount - 2
// bucketSizeF for inner range [1 .. preCount-2].
val bucketSizeF = innerCount.toDouble() / nMiddleBuckets.toDouble()
var outIdx = 0
// First anchor.
var prevX = preCol[0] + 0.5f
var prevY = preVal[0]
outX[outIdx] = prevX
outY[outIdx] = prevY
outIdx++
for (b in 0 until nMiddleBuckets) {
val lo = 1 + (b * bucketSizeF).toInt()
var hi = 1 + ((b + 1) * bucketSizeF).toInt()
if (b == nMiddleBuckets - 1) hi = preCount - 1
if (hi <= lo) continue
// Next bucket average (or last point if this is the final middle bucket).
val nextLo: Int
val nextHi: Int
if (b == nMiddleBuckets - 1) {
nextLo = preCount - 1
nextHi = preCount
} else {
nextLo = 1 + ((b + 1) * bucketSizeF).toInt()
val rawNextHi = 1 + ((b + 2) * bucketSizeF).toInt()
nextHi = rawNextHi.coerceAtMost(preCount - 1)
}
var sumX = 0.0
var sumY = 0.0
var nNext = 0
for (i in nextLo until nextHi) {
sumX += (preCol[i] + 0.5f).toDouble()
sumY += preVal[i].toDouble()
nNext++
}
if (nNext == 0) continue
val nextAvgX = (sumX / nNext).toFloat()
val nextAvgY = (sumY / nNext).toFloat()
// Pick sample in current bucket maximizing triangle area with prev + nextAvg.
var bestArea = -1f
var bestIdx = lo
for (i in lo until hi) {
val curX = preCol[i] + 0.5f
val curY = preVal[i]
val area = abs((prevX - nextAvgX) * (curY - prevY) - (prevX - curX) * (nextAvgY - prevY))
if (area > bestArea) { bestArea = area; bestIdx = i }
}
val pickedX = preCol[bestIdx] + 0.5f
val pickedY = preVal[bestIdx]
outX[outIdx] = pickedX
outY[outIdx] = pickedY
outIdx++
prevX = pickedX
prevY = pickedY
}
// Last anchor.
outX[outIdx] = preCol[preCount - 1] + 0.5f
outY[outIdx] = preVal[preCount - 1]
outIdx++
return outIdx
}
/**
* Fallback: per-pixel-column LTTB on the column-projected stream.
* Used when preselect bucketing would be a no-op (too few samples per bucket).
* Reuses preCol/preVal as scratch zero allocation.
*/
private fun lttbOnColumns(
colArrLocal: IntArray,
valArrLocal: FloatArray,
n: Int,
pixelWidth: Int,
outX: FloatArray,
outY: FloatArray,
): Int = lttbColumnInline(colArrLocal, valArrLocal, n, pixelWidth, outX, outY)
// Inline per-pixel-column LTTB. Uses preCol (Int) and preVal (Float) as scratch:
// - preCol used as bucketCount (Int).
// - preVal used as bucketSumY (Float, single-precision — sufficient for fallback path).
private fun lttbColumnInline(
colArrLocal: IntArray,
valArrLocal: FloatArray,
n: Int,
pixelWidth: Int,
outX: FloatArray,
outY: FloatArray,
): Int {
// Bound checks: pixelWidth ≤ maxPixelWidth so preCol/preVal cover [0..pixelWidth).
for (col in 0 until pixelWidth) { preCol[col] = 0; preVal[col] = 0f }
for (i in 0 until n) { val c = colArrLocal[i]; preCol[c]++; preVal[c] += valArrLocal[i] }
var outIdx = 0
var prevX = 0f
var prevY = 0f
var firstDone = false
var samplePtr = 0
// Compute next non-empty col on the fly via a forward sweep is O(pw^2) worst case;
// since this fallback is cold (only when n ≤ 2 * nBucketsPre), keep it simple.
for (col in 0 until pixelWidth) {
val cnt = preCol[col]
if (cnt == 0) continue
val colX = col + 0.5f
val bucketEnd = samplePtr + cnt
if (!firstDone) {
outX[outIdx] = colX; outY[outIdx] = valArrLocal[samplePtr]
prevX = colX; prevY = valArrLocal[samplePtr]
outIdx++; firstDone = true
samplePtr = bucketEnd
continue
}
// Scan forward for next non-empty column.
var nextCol = -1
for (c2 in col + 1 until pixelWidth) {
if (preCol[c2] > 0) { nextCol = c2; break }
}
if (nextCol < 0) {
outX[outIdx] = colX; outY[outIdx] = valArrLocal[bucketEnd - 1]
outIdx++
samplePtr = bucketEnd
continue
}
val nextAvgX = nextCol + 0.5f
val nextAvgY = preVal[nextCol] / preCol[nextCol].toFloat()
var bestArea = -1f
var bestVal = valArrLocal[samplePtr]
for (i in samplePtr until bucketEnd) {
val area = abs((prevX - nextAvgX) * (valArrLocal[i] - prevY) - (prevX - colX) * (nextAvgY - prevY))
if (area > bestArea) { bestArea = area; bestVal = valArrLocal[i] }
}
outX[outIdx] = colX; outY[outIdx] = bestVal
prevX = colX; prevY = bestVal
outIdx++
samplePtr = bucketEnd
}
return outIdx
}
public companion object {
/** Default preselection ratio per arXiv:2305.00332 / tsdownsample. */
public const val DEFAULT_RATIO: Int = 4
}
}

View file

@ -0,0 +1,28 @@
package dev.dtrentin.chart.model
import androidx.compose.runtime.Immutable
import dev.dtrentin.chart.render.AxisFormatter
import dev.dtrentin.chart.render.DecimalAxisFormatter
import dev.dtrentin.chart.render.TimeAxisFormatter
/**
* Axis-side configuration: label modes, decimals, grid, tick formatters.
*
* @param xLabelMode X-axis tick label placement.
* @param yLabelMode Y-axis tick label placement.
* @param yLabelDecimals decimal digits for the default Y formatter (currently used
* as input to [DecimalAxisFormatter] when [yFormatter] is default; custom
* formatters should be passed via [yFormatter] directly).
* @param showGrid draw grid lines at tick positions.
* @param xFormatter formatter for X-axis tick values (seconds relative to T0).
* @param yFormatter formatter for Y-axis tick values (raw signal value).
*/
@Immutable
public data class AxisConfig(
val xLabelMode: AxisLabelMode = AxisLabelMode.INSIDE,
val yLabelMode: AxisLabelMode = AxisLabelMode.INSIDE,
val yLabelDecimals: Int = 2,
val showGrid: Boolean = true,
val xFormatter: AxisFormatter = TimeAxisFormatter,
val yFormatter: AxisFormatter = DecimalAxisFormatter(decimals = 2),
)

View file

@ -1,6 +1,9 @@
package dev.dtrentin.chart.model package dev.dtrentin.chart.model
import androidx.compose.runtime.Immutable
/** Controls where axis tick labels are drawn. */ /** Controls where axis tick labels are drawn. */
@Immutable
public enum class AxisLabelMode { public enum class AxisLabelMode {
/** Labels drawn over chart data area. No margin reserved. */ /** Labels drawn over chart data area. No margin reserved. */
INSIDE, INSIDE,

View file

@ -1,30 +1,27 @@
package dev.dtrentin.chart.model package dev.dtrentin.chart.model
import androidx.compose.runtime.Immutable
/** /**
* Top-level chart configuration. * Top-level chart configuration. Wraps three orthogonal config groups:
* - [data]: windowing, Y-range strategy, time origin
* - [axis]: tick label modes, decimals, grid, tick formatters
* - [render]: theme, frame rate, LOD strategy
* *
* @param xWindowSeconds visible X range in seconds. Data older than (latestTs - xWindowSeconds) is not rendered. * v0.5.0 BREAKING: replaces the v0.4.0 flat data class. Old fields:
* @param yRange Y axis range strategy. * - `xWindowSeconds`, `yRange`, `t0` `data.xWindowSeconds`, `data.yRange`, `data.t0`
* @param t0 Origin of X axis (seconds = 0). * - `xLabelMode`, `yLabelMode`, `yLabelDecimals`, `showGrid` `axis.*`
* @param targetFps DEPRECATED in v0.4.0. Ignored. Recomposition is now data-driven via the * - `theme`, `lodMode` `render.theme`, `render.lodStrategy`
* Compose snapshot system: Canvas redraws when `dataVersion` changes (Compose batches * - `targetFps` REMOVED (replaced by `render.frameRate`)
* snapshot applies per frame, so writes faster than displayHz still produce at most *
* displayHz recompositions). Will be removed in v0.5.0. * Convenience top-level accessors are exposed for hot fields ([xWindowSeconds], [theme]).
* @param theme Visual theme (colors, stroke widths).
*/ */
@Immutable
public data class ChartConfig( public data class ChartConfig(
val xWindowSeconds: Float = 10f, val data: DataConfig = DataConfig(),
val yRange: YRange = YRange.Auto(), val axis: AxisConfig = AxisConfig(),
val t0: T0 = T0.FirstSample, val render: RenderConfig = RenderConfig(),
@Deprecated( ) {
message = "Ignored since v0.4.0; recomposition is data-driven via Compose snapshots. Will be removed in v0.5.0.", public inline val xWindowSeconds: Float get() = data.xWindowSeconds
level = DeprecationLevel.WARNING, public inline val theme: ChartTheme get() = render.theme
) }
val targetFps: Int? = 30,
val theme: ChartTheme = ChartTheme(),
val showGrid: Boolean = true,
val xLabelMode: AxisLabelMode = AxisLabelMode.INSIDE,
val yLabelMode: AxisLabelMode = AxisLabelMode.INSIDE,
val yLabelDecimals: Int = 2,
val lodMode: LodMode = LodMode.MIN_MAX,
)

View file

@ -2,6 +2,7 @@ package dev.dtrentin.chart.model
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -14,6 +15,7 @@ import androidx.compose.ui.graphics.Color
* @param labelColor Axis tick label text color. * @param labelColor Axis tick label text color.
* @param strokeWidth Axis line stroke width in px. * @param strokeWidth Axis line stroke width in px.
*/ */
@Immutable
public data class ChartTheme( public data class ChartTheme(
val backgroundColor: Color = Color(0xFF1A1A2E), val backgroundColor: Color = Color(0xFF1A1A2E),
val gridColor: Color = Color(0x66FFFFFF), val gridColor: Color = Color(0x66FFFFFF),

View file

@ -0,0 +1,18 @@
package dev.dtrentin.chart.model
import androidx.compose.runtime.Immutable
/**
* Data-side configuration: windowing, Y-range strategy, time origin.
*
* @param xWindowSeconds visible X range in seconds. Samples older than
* `latestTs - xWindowSeconds` are clipped during decimation.
* @param yRange Y axis range strategy ([YRange.Auto] or [YRange.Fixed]).
* @param t0 Origin of the X axis (seconds = 0). See [T0].
*/
@Immutable
public data class DataConfig(
val xWindowSeconds: Float = 10f,
val yRange: YRange = YRange.Auto(),
val t0: T0 = T0.FirstSample,
)

View file

@ -0,0 +1,23 @@
package dev.dtrentin.chart.model
import androidx.compose.runtime.Immutable
/**
* Frame rate strategy for the chart's render loop.
*
* Note: in v0.5.0, recomposition is data-driven via Compose snapshots. FrameRate
* is a forward-looking sealed type wired into RenderConfig; current behavior renders
* on every dataVersion bump (effectively [Display] cadence batched by the snapshot
* system). [Fixed] is reserved for explicit throttling implemented in T8/T9 perf passes.
*/
@Immutable
public sealed class FrameRate {
/** Render at display refresh rate (data-driven, batched by Compose snapshot system). */
public data object Display : FrameRate()
/** Render at most [fps] frames per second. */
@Immutable
public data class Fixed(val fps: Int) : FrameRate() {
init { require(fps > 0) { "fps must be > 0, got $fps" } }
}
}

View file

@ -1,10 +0,0 @@
package dev.dtrentin.chart.model
/** Level-of-Detail decimation strategy applied when visible samples exceed pixel columns. */
public enum class LodMode {
/** Retain min and max per pixel column. Preserves signal peaks. Zero allocations at render time. */
MIN_MAX,
/** Largest-Triangle-Three-Buckets. Visually accurate downsampling. Slight allocation per render frame. */
LTTB,
}

View file

@ -0,0 +1,25 @@
package dev.dtrentin.chart.model
import androidx.compose.runtime.Immutable
import dev.dtrentin.chart.lod.LodStrategy
import dev.dtrentin.chart.lod.MinMaxLttbLodStrategy
/**
* Render-side configuration: theme, frame rate, LOD strategy.
*
* Note: the default [lodStrategy] constructs a fresh [MinMaxLttbLodStrategy] per
* [RenderConfig] (and hence per [ChartConfig]) instance. Each instance allocates
* scratch buffers eagerly. Since [ChartConfig] is typically created once inside
* `remember { }`, this is a one-time cost per composition. For shared strategy
* instances across multiple charts, pass an explicit [LodStrategy].
*
* @param theme Visual theme (colors, stroke widths).
* @param frameRate Frame rate strategy. See [FrameRate].
* @param lodStrategy Level-of-detail decimation strategy. See [LodStrategy].
*/
@Immutable
public data class RenderConfig(
val theme: ChartTheme = ChartTheme(),
val frameRate: FrameRate = FrameRate.Display,
val lodStrategy: LodStrategy = MinMaxLttbLodStrategy(),
)

View file

@ -1,16 +1,24 @@
package dev.dtrentin.chart.model package dev.dtrentin.chart.model
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import dev.dtrentin.chart.render.LineSignalRenderer
import dev.dtrentin.chart.render.SignalRenderer
/** /**
* Per-signal display configuration. * Per-signal display configuration.
* *
* @param color Line color. * @param color Line color.
* @param strokeWidth Line width in dp. * @param strokeWidth Line width in px.
* @param visible If false, signal is skipped during rendering. * @param visible If false, signal is skipped during rendering.
* @param renderer Per-signal renderer override. Defaults to the singleton
* [LineSignalRenderer]; supply a custom [SignalRenderer] to draw e.g.
* areas, bars, or scatter for this signal.
*/ */
@Immutable
public data class SignalConfig( public data class SignalConfig(
val color: Color, val color: Color,
val strokeWidth: Float = 2f, val strokeWidth: Float = 2f,
val visible: Boolean = true, val visible: Boolean = true,
val renderer: SignalRenderer = LineSignalRenderer,
) )

View file

@ -1,13 +1,18 @@
package dev.dtrentin.chart.model package dev.dtrentin.chart.model
import androidx.compose.runtime.Immutable
/** Origin of X axis (t=0 seconds). */ /** Origin of X axis (t=0 seconds). */
@Immutable
public sealed class T0 { public sealed class T0 {
/** Use timestamp of first sample ever pushed as t=0. */ /** Use timestamp of first sample ever pushed as t=0. */
@Immutable
public object FirstSample : T0() public object FirstSample : T0()
/** /**
* Use a fixed epoch timestamp as t=0. * Use a fixed epoch timestamp as t=0.
* @param epochMs absolute timestamp in milliseconds (e.g. System.currentTimeMillis()). * @param epochMs absolute timestamp in milliseconds (e.g. System.currentTimeMillis()).
*/ */
@Immutable
public data class Fixed(val epochMs: Long) : T0() public data class Fixed(val epochMs: Long) : T0()
} }

View file

@ -1,16 +1,21 @@
package dev.dtrentin.chart.model package dev.dtrentin.chart.model
import androidx.compose.runtime.Immutable
/** Y axis range strategy. */ /** Y axis range strategy. */
@Immutable
public sealed class YRange { public sealed class YRange {
/** /**
* Auto-fit to data visible in current X window. * Auto-fit to data visible in current X window.
* @param paddingFraction fractional padding added above and below data range (default 10%). * @param paddingFraction fractional padding added above and below data range (default 10%).
*/ */
@Immutable
public data class Auto(val paddingFraction: Float = 0.1f) : YRange() public data class Auto(val paddingFraction: Float = 0.1f) : YRange()
/** /**
* Fixed range regardless of data. * Fixed range regardless of data.
*/ */
@Immutable
public data class Fixed(val min: Float, val max: Float) : YRange() { public data class Fixed(val min: Float, val max: Float) : YRange() {
init { require(min < max) { "YRange.Fixed: min ($min) must be < max ($max)" } } init { require(min < max) { "YRange.Fixed: min ($min) must be < max ($max)" } }
} }

View file

@ -0,0 +1,115 @@
package dev.dtrentin.chart.render
import androidx.compose.runtime.Immutable
/**
* Strategy for formatting axis tick values to display strings.
*
* Implementations receive a tick value in the axis's native unit:
* - X axis: seconds (Double) relative to T0
* - Y axis: raw signal value (Double)
*
* Defaults provided in this file:
* - [TimeAxisFormatter]: seconds -> "5s", "1:23", "1:00:05" (current X behavior)
* - [DecimalAxisFormatter]: value -> "1.23", "1.23e+05" (current Y behavior)
* - [DateTimeAxisFormatter]: epoch seconds -> UTC "HH:mm:ss" or "HH:mm" (wall-clock)
* - [UnitAxisFormatter]: value -> "5.20 mV", "100.00 Hz" (suffix appended)
*
* Implementations MUST be allocation-conscious: [format] is called once per visible
* tick per frame (typical ~5-10 ticks/axis @ 30 fps = ~600 calls/sec). Default impls
* delegate to [NumberFormat] which performs only short-string concatenation.
*
* Implementations MUST be `@Immutable` for Compose stability (avoids ChartConfig
* recomposition cascades when formatter is part of config).
*
* Co-location decision: interface + 4 default impls in one file (mirrors sealed-family
* pattern of [dev.dtrentin.chart.model.T0]). Easier discovery, single import site.
*
* Wired into [dev.dtrentin.chart.model.AxisConfig.xFormatter] / [dev.dtrentin.chart.model.AxisConfig.yFormatter] (v0.5.0 T5).
*/
@Immutable
public interface AxisFormatter {
/**
* Format a single tick value to a display string.
*
* @param tickValue tick position in axis-native units (seconds for X, raw value for Y).
* @return display string. Must not be null.
*/
public fun format(tickValue: Double): String
}
// ── Defaults ───────────────────────────────────────────────────────────────────
/**
* X-axis formatter matching the legacy [AxisRenderer] behavior: renders seconds
* relative to T0 as `"5s"`, `"1:23"`, or `"1:00:05"`. Negative values prefixed `-`.
*
* Delegates to [NumberFormat.formatTimeSec]. No state, no allocations beyond the
* returned string.
*/
@Immutable
public object TimeAxisFormatter : AxisFormatter {
override fun format(tickValue: Double): String = NumberFormat.formatTimeSec(tickValue)
}
/**
* Y-axis formatter matching the legacy [AxisRenderer] behavior: renders values
* in fixed notation, switching to scientific for `|v| < 0.01` or `|v| >= 1e5`.
*
* Delegates to [NumberFormat.formatAxisValue].
*
* @param decimals decimal digits in fixed and scientific notation (default 2).
*/
@Immutable
public data class DecimalAxisFormatter(val decimals: Int = 2) : AxisFormatter {
override fun format(tickValue: Double): String =
NumberFormat.formatAxisValue(tickValue, decimals)
}
/**
* Wall-clock formatter: treats [tickValue] as **epoch seconds** (Double) and renders
* UTC `HH:mm:ss` (or `HH:mm` if [showSeconds] is `false`).
*
* Trade-off: UTC only no [java.util.Locale]/`TimeZone` dependency keeps this pure
* common code with no platform actuals. Locale-aware variant tracked for post-v1.0.0.
*
* Algorithm: `tickValue.toLong().mod(86400)` then split into h/m/s. Day-of-year is
* intentionally discarded; only time-of-day is rendered. Negative epoch values wrap
* via Kotlin `mod` semantics (always non-negative result).
*
* @param showSeconds include seconds field. Default `true`.
*/
@Immutable
public data class DateTimeAxisFormatter(val showSeconds: Boolean = true) : AxisFormatter {
override fun format(tickValue: Double): String {
val totalSec = tickValue.toLong().mod(86400L)
val h = (totalSec / 3600).toString().padStart(2, '0')
val m = ((totalSec % 3600) / 60).toString().padStart(2, '0')
return if (showSeconds) {
val s = (totalSec % 60).toString().padStart(2, '0')
"$h:$m:$s"
} else "$h:$m"
}
}
/**
* Generic value-with-unit formatter: renders `"<value><separator><unit>"`.
*
* Example: `UnitAxisFormatter("mV").format(5.2)` `"5.20 mV"`.
*
* Value portion delegates to [NumberFormat.formatAxisValue] (same scientific-notation
* thresholds as [DecimalAxisFormatter]).
*
* @param unit unit suffix (e.g. `"mV"`, `"Hz"`, `"°C"`).
* @param decimals decimal digits for the value portion. Default 2.
* @param separator string between value and unit. Default `" "` (single space).
*/
@Immutable
public data class UnitAxisFormatter(
val unit: String,
val decimals: Int = 2,
val separator: String = " ",
) : AxisFormatter {
override fun format(tickValue: Double): String =
"${NumberFormat.formatAxisValue(tickValue, decimals)}$separator$unit"
}

View file

@ -2,6 +2,7 @@ package dev.dtrentin.chart.render
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.text.TextMeasurer import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@ -17,6 +18,46 @@ import kotlin.math.floor
internal object AxisRenderer { internal object AxisRenderer {
// ── T8: TextStyle cache ───────────────────────────────────────────────────
// Per-frame allocations of TextStyle (9.sp, theme.labelColor, optional TextAlign) are
// hoisted into a tiny inline cache keyed by (color.value, alignmentIndex). The cache is
// sized for the realistic max number of distinct styles in flight (one theme color × 3
// alignments). When a theme color changes (rare) the cache simply grows; we cap at
// STYLE_CACHE_MAX and clear-on-overflow.
//
// For label TextLayoutResult dedup we rely on Compose's internal `TextMeasurer.measure`
// LRU (it keys on (text, style, density, layoutDirection)). Hand-maintaining a second
// layout cache here would duplicate that work and risk staleness on density changes.
// See decision log in agent output.
//
// Threading: UI thread only (DrawScope.drawXAxis / drawYAxis are invoked from Canvas).
// No synchronization.
private val styleCache: HashMap<Long, TextStyle> = HashMap(4)
private const val STYLE_CACHE_MAX: Int = 16
// Encode (color, align) into a single Long key.
// - color.value is ULong → cast to Long (bit pattern preserved).
// - align: 0 = none, 1 = Center, 2 = End. Shifted into top 8 bits (unused by Color).
private fun styleKey(colorBits: Long, alignTag: Int): Long =
colorBits xor (alignTag.toLong() shl 56)
private fun textStyleFor(color: Color, align: TextAlign?): TextStyle {
val alignTag = when (align) {
null -> 0
TextAlign.Center -> 1
TextAlign.End -> 2
else -> 3
}
val key = styleKey(color.value.toLong(), alignTag)
val cached = styleCache[key]
if (cached != null) return cached
if (styleCache.size >= STYLE_CACHE_MAX) styleCache.clear()
val fresh = if (align == null) TextStyle(color = color, fontSize = 9.sp)
else TextStyle(color = color, fontSize = 9.sp, textAlign = align)
styleCache[key] = fresh
return fresh
}
fun DrawScope.drawXAxis( fun DrawScope.drawXAxis(
windowStartMs: Long, windowStartMs: Long,
windowMs: Long, windowMs: Long,
@ -37,6 +78,10 @@ internal object AxisRenderer {
var tickSec = floor((windowStartMs / 1000.0) / tickInterval) * tickInterval var tickSec = floor((windowStartMs / 1000.0) / tickInterval) * tickInterval
val windowEndSec = (windowStartMs + windowMs) / 1000.0 val windowEndSec = (windowStartMs + windowMs) / 1000.0
// Hoist styles for this draw call. textStyleFor() returns a cached instance.
val styleDefault = textStyleFor(theme.labelColor, align = null)
val styleCenter = textStyleFor(theme.labelColor, align = TextAlign.Center)
while (tickSec <= windowEndSec + tickInterval * 0.01) { while (tickSec <= windowEndSec + tickInterval * 0.01) {
val fracX = ((tickSec - windowStartMs / 1000.0) / windowSec).toFloat() val fracX = ((tickSec - windowStartMs / 1000.0) / windowSec).toFloat()
val xPx = chartLeft + fracX * chartW val xPx = chartLeft + fracX * chartW
@ -51,7 +96,7 @@ internal object AxisRenderer {
drawText( drawText(
textMeasurer, label, textMeasurer, label,
topLeft = Offset(xPx + 4f, chartBottom - 18f), topLeft = Offset(xPx + 4f, chartBottom - 18f),
style = TextStyle(color = theme.labelColor, fontSize = 9.sp), style = styleDefault,
size = Size(availableW, with(this) { 12.sp.toPx() }), size = Size(availableW, with(this) { 12.sp.toPx() }),
) )
} }
@ -65,7 +110,7 @@ internal object AxisRenderer {
drawText( drawText(
textMeasurer, label, textMeasurer, label,
topLeft = Offset(labelX, chartBottom + 6f), topLeft = Offset(labelX, chartBottom + 6f),
style = TextStyle(color = theme.labelColor, fontSize = 9.sp, textAlign = TextAlign.Center), style = styleCenter,
size = Size(maxW, labelH - 6f), size = Size(maxW, labelH - 6f),
) )
} }
@ -99,6 +144,10 @@ internal object AxisRenderer {
val tickInterval = NumberFormat.niceInterval(range, targetTickCount = 5) val tickInterval = NumberFormat.niceInterval(range, targetTickCount = 5)
var tick = ceil(yMin / tickInterval) * tickInterval var tick = ceil(yMin / tickInterval) * tickInterval
// Hoist styles for this draw call. textStyleFor() returns a cached instance.
val styleDefault = textStyleFor(theme.labelColor, align = null)
val styleEnd = textStyleFor(theme.labelColor, align = TextAlign.End)
while (tick <= yMax + tickInterval * 0.01) { while (tick <= yMax + tickInterval * 0.01) {
val fracY = 1f - ((tick - yMin) / (yMax - yMin)).toFloat() val fracY = 1f - ((tick - yMin) / (yMax - yMin)).toFloat()
val yPx = fracY * chartBottom val yPx = fracY * chartBottom
@ -113,7 +162,7 @@ internal object AxisRenderer {
drawText( drawText(
textMeasurer, label, textMeasurer, label,
topLeft = Offset(chartLeft + 6f, labelY), topLeft = Offset(chartLeft + 6f, labelY),
style = TextStyle(color = theme.labelColor, fontSize = 9.sp), style = styleDefault,
size = Size(chartW - 8f, with(this) { 12.sp.toPx() }), size = Size(chartW - 8f, with(this) { 12.sp.toPx() }),
) )
} }
@ -124,7 +173,7 @@ internal object AxisRenderer {
drawText( drawText(
textMeasurer, label, textMeasurer, label,
topLeft = Offset(2f, labelY), topLeft = Offset(2f, labelY),
style = TextStyle(color = theme.labelColor, fontSize = 9.sp, textAlign = TextAlign.End), style = styleEnd,
size = Size(chartLeft - 12f, with(this) { 12.sp.toPx() }), size = Size(chartLeft - 12f, with(this) { 12.sp.toPx() }),
) )
} }
@ -138,14 +187,28 @@ internal object AxisRenderer {
drawLine(theme.axisColor, Offset(chartLeft, 0f), Offset(chartLeft, chartBottom), strokeWidth = theme.strokeWidth) 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) { * T9: zero-alloc Y-range resolution. Writes `outYRange[0] = yMin`, `outYRange[1] = yMax`.
is YRange.Fixed -> yr.min to yr.max * Caller owns the 2-element FloatArray (allocated once via `remember`).
is YRange.Auto -> { */
internal fun resolveYRange(
config: ChartConfig,
dataMin: Float,
dataMax: Float,
outYRange: FloatArray,
) {
when (val yr = config.data.yRange) {
is YRange.Fixed -> {
outYRange[0] = yr.min
outYRange[1] = yr.max
}
is YRange.Auto -> {
val range = (dataMax - dataMin).coerceAtLeast(1e-6f) val range = (dataMax - dataMin).coerceAtLeast(1e-6f)
val pad = range * yr.paddingFraction val pad = range * yr.paddingFraction
(dataMin - pad) to (dataMax + pad) outYRange[0] = dataMin - pad
outYRange[1] = dataMax + pad
} }
} }
}
} }

View file

@ -0,0 +1,77 @@
package dev.dtrentin.chart.render
import androidx.compose.ui.graphics.Color
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
/**
* Default [SignalRenderer] impl: stroked polyline through pre-decimated points.
*
* Singleton (`object`) for reference-stable default in [dev.dtrentin.chart.model.SignalConfig],
* which keeps `SignalConfig` `@Immutable` equality stable across instances.
*
* Implementation is stateless from the caller's perspective all scratch (Path, FloatArrays)
* is caller-owned and passed in per call.
*
* v0.5.0 T8: a process-wide [Stroke] cache keyed by `strokeWidth` eliminates the per-frame
* `Stroke(width = ...)` allocation. Cache assumes single-threaded Compose UI access (the
* renderer is only ever invoked from the UI thread during Canvas draw). Capped at
* [STROKE_CACHE_MAX] entries (sane upper bound: typical apps use < 8 distinct widths).
* When the cap is hit the cache is cleared rather than running an LRU eviction (simpler;
* a chart that uses > 16 widths is already pathological).
*/
public object LineSignalRenderer : SignalRenderer {
// T8: process-wide stroke cache. Keyed by Float (strokeWidth in px units, as supplied by
// SignalConfig.strokeWidth). Single-threaded UI access assumption — no synchronization.
private val strokeCache: HashMap<Float, Stroke> = HashMap(8)
private const val STROKE_CACHE_MAX: Int = 16
/**
* Internal test hook. Returns the same [Stroke] instance across calls with equal
* [strokeWidth]. Mutates the cache (creates an entry on miss).
*/
internal fun internalStrokeForWidth(strokeWidth: Float): Stroke = strokeForWidth(strokeWidth)
private fun strokeForWidth(strokeWidth: Float): Stroke {
val cached = strokeCache[strokeWidth]
if (cached != null) return cached
if (strokeCache.size >= STROKE_CACHE_MAX) strokeCache.clear()
val fresh = Stroke(width = strokeWidth)
strokeCache[strokeWidth] = fresh
return fresh
}
override fun DrawScope.drawSignal(
color: Color,
strokeWidth: Float,
visible: Boolean,
lodX: FloatArray,
lodY: FloatArray,
count: Int,
path: Path,
chartLeft: Float,
chartRight: Float,
chartBottom: Float,
yMin: Float,
yMax: Float,
) {
if (!visible) return
if (count == 0) return
if (chartBottom <= 0f || chartRight <= chartLeft) 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 count) {
path.lineTo(chartLeft + lodX[i], chartBottom - ((lodY[i] - yMin) * invY) * chartBottom)
}
val stroke = strokeForWidth(strokeWidth)
clipRect(left = chartLeft, top = 0f, right = chartRight, bottom = chartBottom) {
drawPath(path = path, color = color, style = stroke)
}
}
}

View file

@ -34,14 +34,16 @@ internal object NumberFormat {
} }
} }
// TODO(v0.5.0): for |value| >= ~1e19 the cast `(rounded / factor).toLong()` overflows
// Long range and produces garbage digits. Current callers gate by `formatAxisValue`
// (scientific for >=1e5) so production is safe. Fix when exposed publicly.
fun formatFixed(value: Double, decimals: Int): String { fun formatFixed(value: Double, decimals: Int): String {
val negative = value < 0.0 val negative = value < 0.0
val absVal = kotlin.math.abs(value) val absVal = kotlin.math.abs(value)
val factor = pow10(decimals) val factor = pow10(decimals)
val rounded = kotlin.math.round(absVal * factor) val rounded = kotlin.math.round(absVal * factor)
// Guard: |value| >= ~1e19 overflows Long after rounding. Route to scientific
// to preserve sign + arbitrary magnitude correctness.
if (rounded > Long.MAX_VALUE.toDouble()) {
return formatScientific(value, decimals)
}
val whole = (rounded / factor).toLong() val whole = (rounded / factor).toLong()
val frac = (rounded - whole * factor).toLong() val frac = (rounded - whole * factor).toLong()
val sign = if (negative && (whole != 0L || frac != 0L)) "-" else "" val sign = if (negative && (whole != 0L || frac != 0L)) "-" else ""

View file

@ -1,56 +1,57 @@
package dev.dtrentin.chart.render package dev.dtrentin.chart.render
import androidx.compose.runtime.Stable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope 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 { /**
* Strategy for drawing a single signal's pre-decimated points onto a Compose [DrawScope].
*
* Implementations: [LineSignalRenderer] (default singleton). User custom: implement this
* interface and assign to [dev.dtrentin.chart.model.SignalConfig.renderer].
*
* The decimation step is performed BEFORE this renderer is invoked by `RealtimeChart`.
* Inputs are pre-projected screen-space column positions ([lodX]) and raw Y values ([lodY]).
* Implementations transform raw Y pixel Y using ([yMin], [yMax]) into the chart pixel
* rect defined by ([chartLeft], `chartTop = 0`, [chartRight], [chartBottom]).
*
* Public surface uses only primitive types and Compose built-ins no internal types
* (e.g. SignalEntry, LodStrategy) leak through.
*/
@Stable
public interface SignalRenderer {
/** /**
* Draws a single signal path using a pre-populated snapshot stored in [entry]. * Draw a single signal.
* Caller is responsible for invoking [dev.dtrentin.chart.buffer.TieredBuffer.snapshot] *
* once per frame (T11) and setting [entry.scratchCount] before calling this. * @param color stroke color.
* @param strokeWidth stroke width in px.
* @param visible if false, the implementation MUST return immediately without drawing.
* @param lodX decimated X positions in column units (range `0..pixelWidth`,
* `+0.5` for column center). Caller-owned scratch read-only here.
* @param lodY decimated raw Y values (same indexing as [lodX]).
* @param count number of valid entries in [lodX] / [lodY] (`0..lodX.size`).
* @param path reusable [Path] instance for line construction. Implementation MUST
* call `path.reset()` before use.
* @param chartLeft left edge of the chart pixel rect (px).
* @param chartRight right edge of the chart pixel rect (px).
* @param chartBottom bottom edge of the chart pixel rect (px). Top edge is `0`.
* @param yMin minimum raw Y for the current frame (maps to `chartBottom`).
* @param yMax maximum raw Y for the current frame (maps to `0`).
*/ */
fun DrawScope.drawSignal( public fun DrawScope.drawSignal(
entry: SignalEntry, color: Color,
count: Int, strokeWidth: Float,
visible: Boolean,
lodX: FloatArray, lodX: FloatArray,
lodY: FloatArray, lodY: FloatArray,
count: Int,
path: Path, path: Path,
windowStartMs: Long, chartLeft: Float,
windowMs: Long, chartRight: Float,
chartBottom: Float,
yMin: Float, yMin: Float,
yMax: Float, yMax: Float,
chartLeft: Float, )
chartBottom: Float,
lodDecimator: LodDecimator,
mode: LodMode,
) {
if (!entry.config.visible) return
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 = entry.scratchTs, values = entry.scratchV, 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

@ -2,12 +2,15 @@ package dev.dtrentin.chart
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import dev.dtrentin.chart.model.ChartConfig import dev.dtrentin.chart.model.ChartConfig
import dev.dtrentin.chart.model.DataConfig
import dev.dtrentin.chart.model.SignalConfig import dev.dtrentin.chart.model.SignalConfig
import dev.dtrentin.chart.model.T0 import dev.dtrentin.chart.model.T0
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
import kotlin.test.assertNotSame
import kotlin.test.assertNull import kotlin.test.assertNull
import kotlin.test.assertSame
import kotlin.test.assertTrue import kotlin.test.assertTrue
class RealtimeChartStateTest { class RealtimeChartStateTest {
@ -153,19 +156,19 @@ class RealtimeChartStateTest {
// ── resolvedT0Ms / T0 ───────────────────────────────────────────────────── // ── resolvedT0Ms / T0 ─────────────────────────────────────────────────────
@Test fun t0FirstSample_resolvedT0Ms_nullBeforePush() { @Test fun t0FirstSample_resolvedT0Ms_nullBeforePush() {
val state = RealtimeChartState(ChartConfig(t0 = T0.FirstSample)) val state = RealtimeChartState(ChartConfig(data = DataConfig(t0 = T0.FirstSample)))
assertNull(state.resolvedT0Ms) assertNull(state.resolvedT0Ms)
} }
@Test fun t0FirstSample_resolvedT0Ms_setOnFirstPush() { @Test fun t0FirstSample_resolvedT0Ms_setOnFirstPush() {
val state = RealtimeChartState(ChartConfig(t0 = T0.FirstSample)) val state = RealtimeChartState(ChartConfig(data = DataConfig(t0 = T0.FirstSample)))
state.addSignal("s1", defaultSignalConfig) state.addSignal("s1", defaultSignalConfig)
state.push("s1", 5000L, 1f) state.push("s1", 5000L, 1f)
assertEquals(5000L, state.resolvedT0Ms) assertEquals(5000L, state.resolvedT0Ms)
} }
@Test fun t0FirstSample_resolvedT0Ms_notChangedBySubsequentPush() { @Test fun t0FirstSample_resolvedT0Ms_notChangedBySubsequentPush() {
val state = RealtimeChartState(ChartConfig(t0 = T0.FirstSample)) val state = RealtimeChartState(ChartConfig(data = DataConfig(t0 = T0.FirstSample)))
state.addSignal("s1", defaultSignalConfig) state.addSignal("s1", defaultSignalConfig)
state.push("s1", 5000L, 1f) state.push("s1", 5000L, 1f)
state.push("s1", 9000L, 2f) state.push("s1", 9000L, 2f)
@ -174,13 +177,13 @@ class RealtimeChartStateTest {
@Test fun t0Fixed_resolvedT0Ms_setFromConfig() { @Test fun t0Fixed_resolvedT0Ms_setFromConfig() {
val epoch = 1_000_000L val epoch = 1_000_000L
val state = RealtimeChartState(ChartConfig(t0 = T0.Fixed(epoch))) val state = RealtimeChartState(ChartConfig(data = DataConfig(t0 = T0.Fixed(epoch))))
assertEquals(epoch, state.resolvedT0Ms) assertEquals(epoch, state.resolvedT0Ms)
} }
@Test fun t0Fixed_resolvedT0Ms_unchangedAfterPush() { @Test fun t0Fixed_resolvedT0Ms_unchangedAfterPush() {
val epoch = 1_000_000L val epoch = 1_000_000L
val state = RealtimeChartState(ChartConfig(t0 = T0.Fixed(epoch))) val state = RealtimeChartState(ChartConfig(data = DataConfig(t0 = T0.Fixed(epoch))))
state.addSignal("s1", defaultSignalConfig) state.addSignal("s1", defaultSignalConfig)
state.push("s1", epoch + 1000L, 3f) state.push("s1", epoch + 1000L, 3f)
assertEquals(epoch, state.resolvedT0Ms) assertEquals(epoch, state.resolvedT0Ms)
@ -189,7 +192,7 @@ class RealtimeChartStateTest {
// ── config ───────────────────────────────────────────────────────────────── // ── config ─────────────────────────────────────────────────────────────────
@Test fun config_exposedCorrectly() { @Test fun config_exposedCorrectly() {
val cfg = ChartConfig(xWindowSeconds = 30f) val cfg = ChartConfig(data = DataConfig(xWindowSeconds = 30f))
val state = RealtimeChartState(cfg) val state = RealtimeChartState(cfg)
assertEquals(cfg, state.config) assertEquals(cfg, state.config)
} }
@ -218,4 +221,122 @@ class RealtimeChartStateTest {
repeat(1000) { i -> state.push("s", (1000L + i), i.toFloat()) } repeat(1000) { i -> state.push("s", (1000L + i), i.toFloat()) }
assertEquals(before + 1000L, state.dataVersion) assertEquals(before + 1000L, state.dataVersion)
} }
// ── clear() (T10) ─────────────────────────────────────────────────────────
@Test fun clear_purgesAllBuffers() {
val state = RealtimeChartState()
state.addSignal("a", defaultSignalConfig)
state.addSignal("b", defaultSignalConfig)
state.push("a", 1000L, 1f)
state.push("a", 1100L, 2f)
state.push("b", 1200L, 3f)
state.clear()
assertEquals(-1L, state.signals["a"]!!.buffer.latestTimestampMs())
assertEquals(-1L, state.signals["b"]!!.buffer.latestTimestampMs())
}
@Test fun clear_resetsDataVersion() {
val state = RealtimeChartState()
state.addSignal("s1", defaultSignalConfig)
repeat(5) { i -> state.push("s1", (1000L + i), i.toFloat()) }
assertTrue(state.dataVersion > 0L)
state.clear()
assertEquals(0L, state.dataVersion)
}
@Test fun clear_preservesSignalRegistrations() {
val state = RealtimeChartState()
state.addSignal("a", defaultSignalConfig)
state.addSignal("b", defaultSignalConfig)
state.push("a", 1000L, 1f)
state.clear()
assertNotNull(state.signals["a"])
assertNotNull(state.signals["b"])
// push still works post-clear; lastPushedTs sentinel reset → 500L accepted.
state.push("a", 500L, 9f)
assertEquals(500L, state.signals["a"]!!.buffer.latestTimestampMs())
assertEquals(1L, state.dataVersion)
}
@Test fun clear_resetsResolvedT0_firstSample() {
val state = RealtimeChartState(ChartConfig(data = DataConfig(t0 = T0.FirstSample)))
state.addSignal("s1", defaultSignalConfig)
state.push("s1", 5000L, 1f)
assertEquals(5000L, state.resolvedT0Ms)
state.clear()
assertNull(state.resolvedT0Ms)
// next push re-resolves
state.push("s1", 8000L, 1f)
assertEquals(8000L, state.resolvedT0Ms)
}
@Test fun clear_preservesResolvedT0_fixed() {
val epoch = 123L
val state = RealtimeChartState(ChartConfig(data = DataConfig(t0 = T0.Fixed(epoch))))
state.addSignal("s1", defaultSignalConfig)
state.push("s1", 1000L, 1f)
state.clear()
assertEquals(epoch, state.resolvedT0Ms)
}
// Single-threaded thread-safety surrogate, mirrors `pushFromBackgroundThreadIsSafe`:
// interleave push + clear in a tight loop; assert no exception escapes and final
// dataVersion reflects only post-last-clear pushes (clear resets to 0, push increments).
// ── signalsArray cache (T9) ───────────────────────────────────────────────
@Test fun signalsArrayCache_invalidatedOnAddRemove() {
val state = RealtimeChartState()
state.addSignal("a", defaultSignalConfig)
val before = state.signalsArray
assertEquals(1, before.size)
state.addSignal("b", defaultSignalConfig)
val after = state.signalsArray
assertEquals(2, after.size)
assertNotSame(before, after)
state.removeSignal("a")
val afterRemove = state.signalsArray
assertEquals(1, afterRemove.size)
assertNotSame(after, afterRemove)
}
@Test fun signalsArrayCache_stableAcrossPushes() {
val state = RealtimeChartState()
state.addSignal("s1", defaultSignalConfig)
val first = state.signalsArray
// multiple pushes must NOT invalidate the cache — same array instance returned.
state.push("s1", 1000L, 1f)
state.push("s1", 1100L, 2f)
state.push("s1", 1200L, 3f)
val second = state.signalsArray
assertSame(first, second)
}
@Test fun signalsArrayCache_stableAcrossClear() {
val state = RealtimeChartState()
state.addSignal("s1", defaultSignalConfig)
val first = state.signalsArray
state.push("s1", 1000L, 1f)
state.clear()
// clear() preserves signal registrations — entry identity unchanged, cache valid.
val second = state.signalsArray
assertSame(first, second)
}
@Test fun clear_isThreadSafe() {
val state = RealtimeChartState()
state.addSignal("s", defaultSignalConfig)
repeat(100) { i ->
state.push("s", (1000L + i), i.toFloat())
if (i % 10 == 9) state.clear()
}
// Last action above: i=99 → push then clear (99 % 10 == 9). dataVersion == 0.
assertEquals(0L, state.dataVersion)
// Buffer purged.
assertEquals(-1L, state.signals["s"]!!.buffer.latestTimestampMs())
// Push still functional.
state.push("s", 2000L, 7f)
assertEquals(1L, state.dataVersion)
assertEquals(2000L, state.signals["s"]!!.buffer.latestTimestampMs())
}
} }

View file

@ -79,6 +79,103 @@ class CircularBufferTest {
assertEquals(4, buf.currentSize) assertEquals(4, buf.currentSize)
} }
// ---------- T1 (v0.5.0): bisectStart ----------
@Test fun bisectStart_emptyArray_returnsZero() {
val ts = LongArray(8)
assertEquals(0, bisectStart(ts, 0, 100L))
}
@Test fun bisectStart_countZeroOnNonEmptyArray_returnsZero() {
val ts = longArrayOf(10L, 20L, 30L, 40L)
assertEquals(0, bisectStart(ts, 0, 25L))
}
@Test fun bisectStart_targetBelowOldest_returnsZero() {
val ts = longArrayOf(10L, 20L, 30L, 40L, 50L)
assertEquals(0, bisectStart(ts, ts.size, 0L))
assertEquals(0, bisectStart(ts, ts.size, 5L))
assertEquals(0, bisectStart(ts, ts.size, Long.MIN_VALUE))
}
@Test fun bisectStart_targetAboveNewest_returnsCount() {
val ts = longArrayOf(10L, 20L, 30L, 40L, 50L)
assertEquals(5, bisectStart(ts, ts.size, 51L))
assertEquals(5, bisectStart(ts, ts.size, 1_000_000L))
assertEquals(5, bisectStart(ts, ts.size, Long.MAX_VALUE))
}
@Test fun bisectStart_targetExactlyPresent_returnsItsIndex() {
val ts = longArrayOf(10L, 20L, 30L, 40L, 50L)
assertEquals(0, bisectStart(ts, ts.size, 10L))
assertEquals(2, bisectStart(ts, ts.size, 30L))
assertEquals(4, bisectStart(ts, ts.size, 50L))
}
@Test fun bisectStart_targetBetweenAdjacent_returnsUpperIndex() {
val ts = longArrayOf(10L, 20L, 30L, 40L, 50L)
// Between ts[0]=10 and ts[1]=20 → first index where ts[i] >= 15 is 1.
assertEquals(1, bisectStart(ts, ts.size, 15L))
assertEquals(2, bisectStart(ts, ts.size, 21L))
assertEquals(3, bisectStart(ts, ts.size, 35L))
assertEquals(4, bisectStart(ts, ts.size, 45L))
}
@Test fun bisectStart_partialCountIgnoresTrailing() {
// Array has trailing garbage past `count` — must be ignored.
val ts = longArrayOf(10L, 20L, 30L, 99_999L, 99_999L)
// count = 3 → bisect only over [10, 20, 30].
assertEquals(3, bisectStart(ts, 3, 31L)) // above the truncated newest
assertEquals(2, bisectStart(ts, 3, 30L)) // hit
assertEquals(0, bisectStart(ts, 3, 5L)) // below oldest
}
@Test fun bisectStart_onFullBufferSnapshot_returnsCorrectIndex() {
val cap = 8
val buf = CircularBuffer(cap)
for (i in 0 until cap) buf.push(i * 10L, i.toFloat()) // 0,10,20,...,70
val ts = LongArray(cap); val v = FloatArray(cap)
val n = buf.snapshot(ts, v)
assertEquals(cap, n)
assertEquals(0, bisectStart(ts, n, -1L))
assertEquals(0, bisectStart(ts, n, 0L))
assertEquals(3, bisectStart(ts, n, 25L))
assertEquals(3, bisectStart(ts, n, 30L))
assertEquals(n, bisectStart(ts, n, 71L))
}
@Test fun bisectStart_onWrappedBufferSnapshot_returnsCorrectIndex() {
val cap = 5
val buf = CircularBuffer(cap)
// Push 8 — buffer wraps; snapshot must yield chronological [30,40,50,60,70].
for (i in 0..7) buf.push(i * 10L, i.toFloat())
val ts = LongArray(cap); val v = FloatArray(cap)
val n = buf.snapshot(ts, v)
assertEquals(cap, n)
// Sanity: snapshot reordered chronologically.
assertEquals(30L, ts[0]); assertEquals(70L, ts[cap - 1])
// Bisect over the chronologically-ordered snapshot.
assertEquals(0, bisectStart(ts, n, 30L))
assertEquals(0, bisectStart(ts, n, 10L)) // below oldest retained
assertEquals(2, bisectStart(ts, n, 50L)) // exact
assertEquals(2, bisectStart(ts, n, 45L)) // between 40 and 50
assertEquals(n, bisectStart(ts, n, 100L)) // above newest
}
@Test fun bisectStart_propertyMatchesLinearScan() {
// Property: bisectStart result equals first index i where ts[i] >= target (linear ref).
val ts = longArrayOf(-100L, -10L, 0L, 5L, 5L, 5L, 7L, 1000L, 1000L, 9999L)
val count = ts.size
val targets = longArrayOf(Long.MIN_VALUE, -200L, -100L, -50L, -10L, -5L, 0L, 1L, 5L, 6L, 7L, 8L, 999L, 1000L, 1001L, 9998L, 9999L, 10_000L, Long.MAX_VALUE)
for (t in targets) {
var ref = count
for (i in 0 until count) {
if (ts[i] >= t) { ref = i; break }
}
assertEquals(ref, bisectStart(ts, count, t), "mismatch at target=$t")
}
}
@Test fun writeIndexSurvivesIntMaxValue() { @Test fun writeIndexSurvivesIntMaxValue() {
val cap = 16 val cap = 16
val buf = CircularBuffer(cap) val buf = CircularBuffer(cap)

View file

@ -1,189 +0,0 @@
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")
}
// ---------- T15: LTTB branch coverage ----------
@Test fun lttb_belowPixelWidth_returnsExactSamples() {
// n=5, pixelWidth=10 → pass-through (line 41-44).
val ts = longArrayOf(0L, 100L, 200L, 300L, 400L)
val v = floatArrayOf(1f, 2f, 3f, 4f, 5f)
val count = decimator.decimate(ts, v, 5, 0L, 1000L, 10, outX, outY, mode = LodMode.LTTB)
assertEquals(5, count)
for (i in 0 until count) {
assertEquals(v[i], outY[i], "LTTB pass-through must mirror input at i=$i")
}
}
@Test fun lttb_singleColumn_returnsSingleSample() {
// pw=2, windowMs=100 → col boundary at tRel=50.
// Cluster all n samples in col 0: ts cycles through [0, 49].
// n > pixelWidth=2 forces LTTB main branch (bypasses pass-through).
val n = 100
val ts = LongArray(n) { (it % 50).toLong() }
val v = FloatArray(n) { (it + 1).toFloat() }
val count = decimator.decimate(ts, v, n, 0L, 100L, 2, outX, outY, mode = LodMode.LTTB)
assertEquals(1, count, "single occupied column should emit 1 LTTB sample")
assertEquals(0.5f, outX[0], "single-col output X must be col 0 center")
}
@Test fun lttb_twoBucketsBasic() {
// 100 samples spread across 2 cols (pw=2, windowMs=200, ts in [0, 199]).
val n = 100
val ts = LongArray(n) { (it * 2L) } // 0,2,4,...,198
val v = FloatArray(n) { it.toFloat() }
val count = decimator.decimate(ts, v, n, 0L, 200L, 2, outX, outY, mode = LodMode.LTTB)
assertEquals(2, count, "LTTB across 2 cols should emit 2 points, got $count")
// First col anchor = first sample value.
assertEquals(0f, outY[0])
// Second col is the last bucket (nextCol < 0 path) → last sample value.
// Last sample of second bucket: largest ts < 200 with col=1 → ts=198 (i=99, v=99f).
assertEquals(99f, outY[1])
assertTrue(outX[1] > outX[0], "outX must be monotonic")
}
@Test fun lttb_triangleAreaSelectionPicksOutlier() {
// 3 buckets. Middle bucket has one outlier with large deviation.
// pw=3, windowMs=300, ts split across cols [0..99],[100..199],[200..299].
// Bucket layout:
// col 0: ts 0,10,20,30,40,50,60,70,80,90 (v=1f all)
// col 1: ts 100,110,...,180 (v=1f) + one outlier ts=150, v=1000f
// col 2: ts 200,210,...,290 (v=1f all)
val tsList = mutableListOf<Long>()
val vList = mutableListOf<Float>()
for (t in 0L..90L step 10L) { tsList += t; vList += 1f }
for (t in 100L..140L step 10L) { tsList += t; vList += 1f }
tsList += 150L; vList += 1000f // outlier
for (t in 160L..190L step 10L) { tsList += t; vList += 1f }
for (t in 200L..290L step 10L) { tsList += t; vList += 1f }
val n = tsList.size
val ts = LongArray(n) { tsList[it] }
val v = FloatArray(n) { vList[it] }
val count = decimator.decimate(ts, v, n, 0L, 300L, 3, outX, outY, mode = LodMode.LTTB)
assertEquals(3, count, "expected 3 LTTB points across 3 cols")
// Middle output must be the outlier (area maximization).
assertEquals(1000f, outY[1], "LTTB middle bucket must pick outlier value 1000f")
}
@Test fun lttb_emptyBucketsSkippedViaNextNonEmpty() {
// pw=10, windowMs=1000. Place samples only in cols 0, 5, 9 → 3 nonempty cols.
// Need n > pw=10 to bypass pass-through → cluster multiple samples per nonempty col.
val tsList = mutableListOf<Long>()
val vList = mutableListOf<Float>()
// col 0: tRel ∈ [0, 100) → ts 0..99 step 10 (10 samples).
for (t in 0L..90L step 10L) { tsList += t; vList += 1f }
// col 5: tRel ∈ [500, 600).
for (t in 500L..590L step 10L) { tsList += t; vList += 2f }
// col 9: tRel ∈ [900, 1000).
for (t in 900L..990L step 10L) { tsList += t; vList += 3f }
val n = tsList.size
val ts = LongArray(n) { tsList[it] }
val v = FloatArray(n) { vList[it] }
assertTrue(n > 10, "must exceed pixelWidth=10 to enter LTTB path")
val count = decimator.decimate(ts, v, n, 0L, 1000L, 10, outX, outY, mode = LodMode.LTTB)
assertEquals(3, count, "LTTB must skip empty buckets, expected 3 outputs")
// outX values map to cols 0, 5, 9 → 0.5, 5.5, 9.5.
assertEquals(0.5f, outX[0])
assertEquals(5.5f, outX[1])
assertEquals(9.5f, outX[2])
}
@Test fun lttb_zeroPixelWidth_returnsZero() {
val count = decimator.decimate(longArrayOf(0L), floatArrayOf(1f), 1, 0L, 1000L, 0, outX, outY, mode = LodMode.LTTB)
assertEquals(0, count)
}
@Test fun lttb_zeroSamples_returnsZero() {
val count = decimator.decimate(LongArray(0), FloatArray(0), 0, 0L, 1000L, 100, outX, outY, mode = LodMode.LTTB)
assertEquals(0, count)
}
@Test fun lttb_windowMsZero_returnsZero() {
val count = decimator.decimate(longArrayOf(0L), floatArrayOf(1f), 1, 0L, 0L, 100, outX, outY, mode = LodMode.LTTB)
assertEquals(0, count)
}
@Test fun lttb_outOfWindowSamplesFiltered() {
// ts entries outside [windowStartMs, windowStartMs+windowMs] are dropped (line 33-34).
// Use windowStartMs=100, windowMs=200 → in-window ts ∈ [100, 300].
val ts = longArrayOf(50L, 99L, 100L, 200L, 300L, 301L, 500L)
val v = floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f, 7f)
// 7 samples but only 3 in window — n in LTTB will be ≤3 < pixelWidth → pass-through.
val count = decimator.decimate(ts, v, ts.size, 100L, 200L, 100, outX, outY, mode = LodMode.LTTB)
assertEquals(3, count, "only in-window samples should survive filter")
// outY must come from in-window v values {3f, 4f, 5f}.
val ySet = outY.copyOf(count).toSet()
assertTrue(ySet.contains(3f), "in-window v=3f missing")
assertTrue(ySet.contains(4f), "in-window v=4f missing")
assertTrue(ySet.contains(5f), "in-window v=5f missing")
assertTrue(!ySet.contains(1f), "out-of-window v=1f leaked through")
assertTrue(!ySet.contains(7f), "out-of-window v=7f leaked through")
}
@Test fun lttb_orderPreserved_outputXMonotonic() {
// Larger n > pixelWidth across many cols → LTTB main path.
val n = 500
val ts = LongArray(n) { it.toLong() }
val v = FloatArray(n) { kotlin.math.sin(it.toFloat() / 20f) }
val count = decimator.decimate(ts, v, n, 0L, n.toLong(), 50, outX, outY, mode = LodMode.LTTB)
assertTrue(count > 0, "expected non-empty LTTB output")
for (i in 1 until count) {
assertTrue(outX[i] > outX[i - 1], "outX must be strictly increasing at i=$i: ${outX[i - 1]} -> ${outX[i]}")
}
}
}

View file

@ -410,6 +410,177 @@ class TieredBufferTest {
assertEquals(0, n) assertEquals(0, n)
} }
// ---------- T1 (v0.5.0): snapshotWindow equivalence with snapshot ----------
/** Assert two snapshot result pairs hold the same (count, ts[i], v[i]) tuples. */
private fun assertSnapshotEquivalent(
nA: Int, tsA: LongArray, vsA: FloatArray,
nB: Int, tsB: LongArray, vsB: FloatArray,
msg: String,
) {
assertEquals(nA, nB, "$msg: count mismatch")
for (i in 0 until nA) {
assertEquals(tsA[i], tsB[i], "$msg: ts mismatch at i=$i")
assertEquals(vsA[i], vsB[i], "$msg: v mismatch at i=$i")
}
}
@Test fun snapshotWindow_emptyBuffer_returnsZero() {
val buf = TieredBuffer()
val n = buf.snapshotWindow(0L, 10_000L, outTs(), outVs())
assertEquals(0, n)
}
@Test fun snapshotWindow_windowBeforeAnyData_returnsZero() {
val buf = TieredBuffer()
buf.push(10_000L, 1f)
buf.push(10_500L, 2f)
val n = buf.snapshotWindow(0L, 5_000L, outTs(), outVs())
assertEquals(0, n)
}
@Test fun snapshotWindow_windowEntirelyAfterData_returnsZero() {
val buf = TieredBuffer()
buf.push(500L, 1f)
buf.push(1_000L, 2f)
val n = buf.snapshotWindow(10_000L, 10_000L, outTs(), outVs())
assertEquals(0, n)
}
@Test fun snapshotWindow_tier0Only_equalsSnapshot() {
val buf = TieredBuffer()
val srcTs = longArrayOf(10L, 20L, 30L, 40L, 50L)
val srcVs = floatArrayOf(1f, 2f, 3f, 4f, 5f)
for (i in srcTs.indices) buf.push(srcTs[i], srcVs[i])
val tsA = outTs(); val vsA = outVs()
val tsB = outTs(); val vsB = outVs()
val nA = buf.snapshot(0L, 10_000L, tsA, vsA)
val nB = buf.snapshotWindow(0L, 10_000L, tsB, vsB)
assertSnapshotEquivalent(nA, tsA, vsA, nB, tsB, vsB, "tier0Only")
}
@Test fun snapshotWindow_tier0Only_narrowSubWindow_equalsSnapshot() {
val buf = TieredBuffer()
for (i in 0 until 20) buf.push(i * 5L, i.toFloat()) // ts 0..95 step 5
val tsA = outTs(); val vsA = outVs()
val tsB = outTs(); val vsB = outVs()
// Window [25, 70) → expect ts in {25,30,...,65}.
val nA = buf.snapshot(25L, 45L, tsA, vsA)
val nB = buf.snapshotWindow(25L, 45L, tsB, vsB)
assertSnapshotEquivalent(nA, tsA, vsA, nB, tsB, vsB, "tier0NarrowWindow")
// Sanity: every returned ts strictly inside [25, 70).
for (i in 0 until nB) {
assertTrue(tsB[i] in 25L..69L, "out-of-window ts at i=$i: ${tsB[i]}")
}
}
@Test fun snapshotWindow_spansT0T1Boundary_equalsSnapshot() {
val buf = TieredBuffer()
// Old tier1 candidates.
buf.push(0L, 1f); buf.push(50L, 2f); buf.push(99L, 3f)
buf.push(100L, 4f) // flush bin [0,100)
// Push past tier0 retention so old samples exit tier0.
val nowTs = TieredBuffer.TIER0_DURATION_MS + 1000L
buf.push(nowTs, 5f)
buf.push(nowTs + 100L, 6f)
val tsA = outTs(); val vsA = outVs()
val tsB = outTs(); val vsB = outVs()
val nA = buf.snapshot(0L, nowTs + 200L, tsA, vsA)
val nB = buf.snapshotWindow(0L, nowTs + 200L, tsB, vsB)
assertSnapshotEquivalent(nA, tsA, vsA, nB, tsB, vsB, "spansT0T1")
}
@Test fun snapshotWindow_spansT1T2Boundary_equalsSnapshot() {
val buf = TieredBuffer()
// Tier2 candidates.
buf.push(0L, 1f); buf.push(500L, 2f)
buf.push(1000L, 3f) // roll tier2 bin
// Tier1 region.
val tier1Start = TieredBuffer.TIER1_DURATION_MS
buf.push(tier1Start, 10f)
buf.push(tier1Start + 50L, 11f)
buf.push(tier1Start + 100L, 12f)
// Push past tier0 + tier1 retention.
val nowTs = TieredBuffer.TIER0_DURATION_MS + TieredBuffer.TIER1_DURATION_MS + 2000L
buf.push(nowTs, 99f)
val tsA = outTs(); val vsA = outVs()
val tsB = outTs(); val vsB = outVs()
val nA = buf.snapshot(0L, nowTs + 100L, tsA, vsA)
val nB = buf.snapshotWindow(0L, nowTs + 100L, tsB, vsB)
assertSnapshotEquivalent(nA, tsA, vsA, nB, tsB, vsB, "spansT1T2")
}
@Test fun snapshotWindow_spansAll3Tiers_equalsSnapshot() {
val buf = TieredBuffer()
// Tier2 region.
buf.push(0L, 1f)
buf.push(1000L, 2f) // flush bin [0,1000)
// Tier1 region.
val tier1Start = TieredBuffer.TIER1_DURATION_MS
buf.push(tier1Start, 5f)
buf.push(tier1Start + 100L, 6f) // flush bin
// Tier0 fresh.
val nowTs = TieredBuffer.TIER0_DURATION_MS + TieredBuffer.TIER1_DURATION_MS + 1000L
buf.push(nowTs, 10f)
buf.push(nowTs + 50L, 11f)
val tsA = outTs(); val vsA = outVs()
val tsB = outTs(); val vsB = outVs()
val nA = buf.snapshot(0L, nowTs + 200L, tsA, vsA)
val nB = buf.snapshotWindow(0L, nowTs + 200L, tsB, vsB)
assertSnapshotEquivalent(nA, tsA, vsA, nB, tsB, vsB, "spansAll3Tiers")
}
@Test fun snapshotWindow_windowEntirelyInTier2_equalsSnapshot() {
val buf = TieredBuffer()
// Two tier2 bins.
buf.push(0L, 1f); buf.push(400L, 2f); buf.push(900L, 3f)
buf.push(1000L, 4f) // roll tier2 bin → flush [0,1000)
buf.push(1500L, 5f); buf.push(1900L, 6f)
buf.push(2000L, 7f) // roll tier2 bin → flush [1000,2000)
// Force snapshot from tier2 only.
val nowTs = TieredBuffer.TIER0_DURATION_MS + TieredBuffer.TIER1_DURATION_MS + 5000L
buf.push(nowTs, 0f)
val tsA = outTs(); val vsA = outVs()
val tsB = outTs(); val vsB = outVs()
// Tight window inside tier2 horizon.
val nA = buf.snapshot(0L, 2000L, tsA, vsA)
val nB = buf.snapshotWindow(0L, 2000L, tsB, vsB)
assertSnapshotEquivalent(nA, tsA, vsA, nB, tsB, vsB, "windowInTier2")
assertTrue(nB >= 1, "expected ≥1 tier2 record, got $nB")
}
@Test fun snapshotWindow_sweepingWindows_equalsSnapshot() {
// Cross-validate snapshotWindow vs snapshot across many sub-windows of a dense tier0 stream.
val buf = TieredBuffer()
// 1000 samples at 5ms step → ts in [0, 4995].
for (i in 0 until 1000) buf.push(i * 5L, i.toFloat())
val tsA = outTs(); val vsA = outVs()
val tsB = outTs(); val vsB = outVs()
val windows = listOf(
0L to 100L,
10L to 50L,
12L to 30L, // off-grid bounds
500L to 2000L,
4990L to 100L, // tail
5000L to 1000L, // beyond data
-100L to 50L, // partially before data
0L to 5000L, // full
)
for ((start, span) in windows) {
val nA = buf.snapshot(start, span, tsA, vsA)
val nB = buf.snapshotWindow(start, span, tsB, vsB)
assertSnapshotEquivalent(nA, tsA, vsA, nB, tsB, vsB, "sweep[start=$start,span=$span]")
}
}
@Test fun tier1BinHandlesSamePostFlushTimestamp() { @Test fun tier1BinHandlesSamePostFlushTimestamp() {
val buf = TieredBuffer() val buf = TieredBuffer()
// Bin [0, 100): single sample. // Bin [0, 100): single sample.

View file

@ -0,0 +1,143 @@
package dev.dtrentin.chart.interaction
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ChartInteractionStateTest {
private fun newState(config: InteractionConfig = InteractionConfig()): ChartInteractionState =
ChartInteractionState(config)
// ── applyZoom ─────────────────────────────────────────────────────────────
@Test fun applyZoom_clampsToMin() {
val cfg = InteractionConfig(minXWindowSeconds = 1f, maxXWindowSeconds = 60f)
val s = newState(cfg)
// First zoom uses fallback 10s; scale 100 → 10/100 = 0.1 → clamped to 1f.
s.applyZoom(scale = 100f, fallbackXWindowSeconds = 10f)
assertEquals(1f, s.xWindowSecondsOverride)
}
@Test fun applyZoom_clampsToMax() {
val cfg = InteractionConfig(minXWindowSeconds = 1f, maxXWindowSeconds = 60f)
val s = newState(cfg)
// scale 0.01 → 10/0.01 = 1000 → clamped to 60f.
s.applyZoom(scale = 0.01f, fallbackXWindowSeconds = 10f)
assertEquals(60f, s.xWindowSecondsOverride)
}
@Test fun applyZoom_disabled_noop() {
val s = newState(InteractionConfig(zoomEnabled = false))
s.applyZoom(scale = 2f, fallbackXWindowSeconds = 10f)
assertEquals(0f, s.xWindowSecondsOverride) // 0 = no override applied
}
@Test fun applyZoom_subsequentCallsCompound() {
val s = newState(InteractionConfig(minXWindowSeconds = 0.01f, maxXWindowSeconds = 1000f))
s.applyZoom(scale = 2f, fallbackXWindowSeconds = 10f) // → 5
assertEquals(5f, s.xWindowSecondsOverride)
s.applyZoom(scale = 2f, fallbackXWindowSeconds = 999f) // fallback ignored; 5/2 = 2.5
assertEquals(2.5f, s.xWindowSecondsOverride)
}
@Test fun applyZoom_invalidScaleNoop() {
val s = newState()
s.applyZoom(scale = 0f, fallbackXWindowSeconds = 10f)
s.applyZoom(scale = -1f, fallbackXWindowSeconds = 10f)
s.applyZoom(scale = Float.NaN, fallbackXWindowSeconds = 10f)
assertEquals(0f, s.xWindowSecondsOverride)
}
// ── applyPan ──────────────────────────────────────────────────────────────
@Test fun applyPan_setsHistoryMode() {
val s = newState()
s.applyPan(deltaMs = -5000L, latestTsMs = 1_000_000L)
assertEquals(-5000L, s.viewportOffsetMs)
val m = s.mode
assertTrue(m is ViewportMode.History)
assertEquals(1_000_000L - 5000L, m.anchorMs)
}
@Test fun applyPan_resumeFollowingAtLiveEdge() {
val s = newState(InteractionConfig(resumeFollowingThresholdMs = 500L))
// pan back, then pan forward to within threshold.
s.applyPan(deltaMs = -5000L, latestTsMs = 1_000_000L)
assertTrue(s.mode is ViewportMode.History)
// forward delta brings offset to -100ms (within 500ms threshold) → resume.
s.applyPan(deltaMs = 4900L, latestTsMs = 1_000_000L)
assertEquals(0L, s.viewportOffsetMs)
assertEquals(ViewportMode.Following, s.mode)
}
@Test fun applyPan_disabled_noop() {
val s = newState(InteractionConfig(panEnabled = false))
s.applyPan(deltaMs = -5000L, latestTsMs = 1_000_000L)
assertEquals(0L, s.viewportOffsetMs)
assertEquals(ViewportMode.Following, s.mode)
}
@Test fun applyPan_cumulative() {
val s = newState(InteractionConfig(resumeFollowingThresholdMs = 100L))
s.applyPan(deltaMs = -1000L, latestTsMs = 1_000_000L)
s.applyPan(deltaMs = -500L, latestTsMs = 1_000_000L)
assertEquals(-1500L, s.viewportOffsetMs)
val m = s.mode
assertTrue(m is ViewportMode.History)
assertEquals(1_000_000L - 1500L, m.anchorMs)
}
// ── toggleCrosshair ───────────────────────────────────────────────────────
@Test fun toggleCrosshair_setThenDismiss() {
val s = newState()
assertNull(s.crosshair)
val ch = CrosshairState(pixelX = 100f, timestampMs = 1234L, signalValues = emptyList())
s.toggleCrosshair(ch)
assertNotNull(s.crosshair)
assertEquals(ch, s.crosshair)
s.toggleCrosshair(null)
assertNull(s.crosshair)
}
@Test fun toggleCrosshair_setWhenActive_dismisses() {
// Toggling with a NEW state when one is already active should still dismiss
// (toggle semantic — second tap clears regardless of payload).
val s = newState()
val a = CrosshairState(pixelX = 1f, timestampMs = 1L, signalValues = emptyList())
val b = CrosshairState(pixelX = 2f, timestampMs = 2L, signalValues = emptyList())
s.toggleCrosshair(a)
s.toggleCrosshair(b)
assertNull(s.crosshair)
}
@Test fun toggleCrosshair_disabled_alwaysNull() {
val s = newState(InteractionConfig(tapCrosshairEnabled = false))
val ch = CrosshairState(pixelX = 100f, timestampMs = 1234L, signalValues = emptyList())
s.toggleCrosshair(ch)
assertNull(s.crosshair)
}
// ── resumeFollowing ───────────────────────────────────────────────────────
@Test fun resumeFollowing_resetsOffsetAndMode() {
val s = newState()
s.applyPan(deltaMs = -10_000L, latestTsMs = 5_000_000L)
assertTrue(s.viewportOffsetMs < 0L)
assertTrue(s.mode is ViewportMode.History)
s.resumeFollowing()
assertEquals(0L, s.viewportOffsetMs)
assertEquals(ViewportMode.Following, s.mode)
}
@Test fun initialState_defaults() {
val s = newState()
assertEquals(ViewportMode.Following, s.mode)
assertEquals(0L, s.viewportOffsetMs)
assertEquals(0f, s.xWindowSecondsOverride)
assertNull(s.crosshair)
}
}

View file

@ -0,0 +1,158 @@
package dev.dtrentin.chart.interaction
import androidx.compose.ui.graphics.Color
import dev.dtrentin.chart.SignalEntry
import dev.dtrentin.chart.buffer.TieredBuffer
import dev.dtrentin.chart.model.SignalConfig
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class InverseProjectionTest {
// ── pixelXToTimestampMs ───────────────────────────────────────────────────
@Test fun pixelXToTimestampMs_leftEdge() {
val ts = InverseProjection.pixelXToTimestampMs(
pixelX = 0f,
chartLeft = 0f,
chartRight = 1000f,
windowStartMs = 100_000L,
windowMs = 10_000L,
)
assertEquals(100_000L, ts)
}
@Test fun pixelXToTimestampMs_rightEdge() {
val ts = InverseProjection.pixelXToTimestampMs(
pixelX = 1000f,
chartLeft = 0f,
chartRight = 1000f,
windowStartMs = 100_000L,
windowMs = 10_000L,
)
assertEquals(110_000L, ts)
}
@Test fun pixelXToTimestampMs_middle() {
val ts = InverseProjection.pixelXToTimestampMs(
pixelX = 500f,
chartLeft = 0f,
chartRight = 1000f,
windowStartMs = 100_000L,
windowMs = 10_000L,
)
assertEquals(105_000L, ts)
}
@Test fun pixelXToTimestampMs_clampsBelowChartLeft() {
val ts = InverseProjection.pixelXToTimestampMs(
pixelX = -50f,
chartLeft = 0f,
chartRight = 1000f,
windowStartMs = 100_000L,
windowMs = 10_000L,
)
assertEquals(100_000L, ts) // clamped to left edge
}
@Test fun pixelXToTimestampMs_clampsAboveChartRight() {
val ts = InverseProjection.pixelXToTimestampMs(
pixelX = 5000f,
chartLeft = 0f,
chartRight = 1000f,
windowStartMs = 100_000L,
windowMs = 10_000L,
)
assertEquals(110_000L, ts) // clamped to right edge
}
@Test fun pixelXToTimestampMs_withChartLeftInset() {
// chartLeft=52 (Y-axis label inset), chartRight=552 → 500px chart area.
val ts = InverseProjection.pixelXToTimestampMs(
pixelX = 302f, // 250 px into chart area = 50%
chartLeft = 52f,
chartRight = 552f,
windowStartMs = 200_000L,
windowMs = 4_000L,
)
assertEquals(202_000L, ts)
}
@Test fun pixelXToTimestampMs_degenerateChartWidth_returnsWindowStart() {
// chartRight == chartLeft → coerced to width 1; frac clamped to [0,1].
val ts = InverseProjection.pixelXToTimestampMs(
pixelX = 100f,
chartLeft = 100f,
chartRight = 100f,
windowStartMs = 50_000L,
windowMs = 1000L,
)
// pixelX - chartLeft = 0 → frac = 0 → windowStartMs.
assertEquals(50_000L, ts)
}
// ── nearestSampleValue ────────────────────────────────────────────────────
private fun newEntry(): SignalEntry =
SignalEntry(SignalConfig(color = Color.Red), TieredBuffer())
/** Populate [entry] scratch arrays directly with [samples] (ts, v). */
private fun populate(entry: SignalEntry, samples: List<Pair<Long, Float>>) {
for ((i, s) in samples.withIndex()) {
entry.scratchTs[i] = s.first
entry.scratchV[i] = s.second
}
entry.scratchCount = samples.size
}
@Test fun nearestSampleValue_exactMatch() {
val e = newEntry()
populate(e, listOf(100L to 1f, 200L to 2f, 300L to 3f))
assertEquals(2f, InverseProjection.nearestSampleValue(e, 200L))
}
@Test fun nearestSampleValue_betweenSamples_picksNearer() {
val e = newEntry()
populate(e, listOf(100L to 1f, 200L to 2f, 300L to 3f))
assertEquals(2f, InverseProjection.nearestSampleValue(e, 230L)) // closer to 200
assertEquals(3f, InverseProjection.nearestSampleValue(e, 270L)) // closer to 300
}
@Test fun nearestSampleValue_tieBreaksLower() {
val e = newEntry()
populate(e, listOf(100L to 1f, 200L to 2f))
// Midpoint 150: |100-150| == |200-150| == 50; idxA <= idxB → idxA = lo-1 = 0 → value 1f.
assertEquals(1f, InverseProjection.nearestSampleValue(e, 150L))
}
@Test fun nearestSampleValue_targetBeforeAllSamples() {
val e = newEntry()
populate(e, listOf(500L to 5f, 600L to 6f))
assertEquals(5f, InverseProjection.nearestSampleValue(e, 100L))
}
@Test fun nearestSampleValue_targetAfterAllSamples() {
val e = newEntry()
populate(e, listOf(500L to 5f, 600L to 6f))
assertEquals(6f, InverseProjection.nearestSampleValue(e, 10_000L))
}
@Test fun nearestSampleValue_emptyEntry_returnsNaN() {
val e = newEntry()
// scratchCount stays 0
val v = InverseProjection.nearestSampleValue(e, 100L)
assertTrue(v.isNaN())
}
@Test fun nearestSampleIndex_emptyReturnsNegOne() {
val e = newEntry()
assertEquals(-1, InverseProjection.nearestSampleIndex(e, 100L))
}
@Test fun nearestSampleIndex_findsClosest() {
val e = newEntry()
populate(e, listOf(100L to 1f, 200L to 2f, 300L to 3f))
assertEquals(1, InverseProjection.nearestSampleIndex(e, 220L))
}
}

View file

@ -0,0 +1,108 @@
package dev.dtrentin.chart.lod
import kotlin.test.*
class LttbLodStrategyTest {
private val strategy = LttbLodStrategy()
private val outX = FloatArray(8192)
private val outY = FloatArray(8192)
@Test fun emptyInput_returnsZero() {
assertEquals(0, strategy.decimate(LongArray(0), FloatArray(0), 0, 0L, 1000L, 100, outX, outY))
}
@Test fun zeroPixelWidth_returnsZero() {
assertEquals(0, strategy.decimate(longArrayOf(0L), floatArrayOf(1f), 1, 0L, 1000L, 0, outX, outY))
}
@Test fun zeroWindowMs_returnsZero() {
assertEquals(0, strategy.decimate(longArrayOf(0L), floatArrayOf(1f), 1, 0L, 0L, 100, outX, outY))
}
@Test fun fewerSamplesThanPixels_passThrough() {
val ts = longArrayOf(0L, 500L, 1000L)
val v = floatArrayOf(1f, 2f, 3f)
// windowMs=1001 keeps the 1000L sample inside half-open [0, 1001).
val n = strategy.decimate(ts, v, 3, 0L, 1001L, 100, outX, outY)
assertEquals(3, n)
assertEquals(1f, outY[0]); assertEquals(2f, outY[1]); assertEquals(3f, outY[2])
}
// ── Golden-output tests (replace v0.4.0 LodDecimator parity tests) ─────────
@Test fun upperBoundIsHalfOpen() {
// D3: snapshot semantic is half-open [windowStartMs, windowStartMs+windowMs).
// Sample at tRel == windowMs must be excluded.
val ts = longArrayOf(0L, 50L, 100L, 200L)
val v = floatArrayOf(1f, 2f, 3f, 999f)
val count = strategy.decimate(ts, v, 4, 0L, 200L, 100, outX, outY)
// Boundary sample excluded → n=3 ≤ pixelWidth=100 → pass-through path with 3 points.
assertEquals(3, count)
for (i in 0 until count) {
assertTrue(outY[i] != 999f, "boundary sample at tRel==windowMs must be excluded")
}
}
@Test fun goldenOutput_singleColumn_returnsSingleSample() {
// pw=2, windowMs=100 → col boundary at tRel=50. Cluster all n samples in col 0.
// n=100 > pw=2 → LTTB decimation path. Single non-empty col → 1 output point.
val n = 100
val ts = LongArray(n) { (it % 50).toLong() }
val v = FloatArray(n) { (it + 1).toFloat() }
val count = strategy.decimate(ts, v, n, 0L, 100L, 2, outX, outY)
assertEquals(1, count, "single occupied column should emit 1 LTTB sample")
assertEquals(0.5f, outX[0], "single-col output X must be col 0 center")
}
@Test fun goldenOutput_sparseCols_threeNonEmpty() {
// Match v0.4.0 `lttb_emptyBucketsSkippedViaNextNonEmpty` scenario.
// pw=10, windowMs=1000. Samples only in cols 0, 5, 9.
// n > pw=10 forces LTTB main path. Expect 3 outputs at col centers 0.5, 5.5, 9.5.
val tsList = mutableListOf<Long>()
val vList = mutableListOf<Float>()
for (t in 0L..90L step 10L) { tsList += t; vList += 1f }
for (t in 500L..590L step 10L) { tsList += t; vList += 2f }
for (t in 900L..990L step 10L) { tsList += t; vList += 3f }
val n = tsList.size
val ts = LongArray(n) { tsList[it] }
val v = FloatArray(n) { vList[it] }
assertTrue(n > 10)
val count = strategy.decimate(ts, v, n, 0L, 1000L, 10, outX, outY)
assertEquals(3, count)
assertEquals(0.5f, outX[0])
assertEquals(5.5f, outX[1])
assertEquals(9.5f, outX[2])
}
@Test fun goldenOutput_twoBuckets_anchorsToFirstAndLastValue() {
// 100 samples spread across 2 cols (pw=2, windowMs=200, ts in [0, 199]).
// First col anchor = first sample (v=0). Second col is last bucket
// (nextCol < 0 branch) → last sample value (v=99).
val n = 100
val ts = LongArray(n) { (it * 2L) } // 0, 2, 4, ..., 198
val v = FloatArray(n) { it.toFloat() }
val count = strategy.decimate(ts, v, n, 0L, 200L, 2, outX, outY)
assertEquals(2, count)
assertEquals(0f, outY[0])
assertEquals(99f, outY[1])
assertTrue(outX[1] > outX[0])
}
@Test fun goldenOutput_triangleArea_picksOutlier() {
// 3 cols. Middle col has 1000f outlier. LTTB area maximization must pick it.
val tsList = mutableListOf<Long>()
val vList = mutableListOf<Float>()
for (t in 0L..90L step 10L) { tsList += t; vList += 1f }
for (t in 100L..140L step 10L) { tsList += t; vList += 1f }
tsList += 150L; vList += 1000f
for (t in 160L..190L step 10L) { tsList += t; vList += 1f }
for (t in 200L..290L step 10L) { tsList += t; vList += 1f }
val n = tsList.size
val ts = LongArray(n) { tsList[it] }
val v = FloatArray(n) { vList[it] }
val count = strategy.decimate(ts, v, n, 0L, 300L, 3, outX, outY)
assertEquals(3, count)
assertEquals(1000f, outY[1])
}
}

View file

@ -0,0 +1,109 @@
package dev.dtrentin.chart.lod
import kotlin.test.*
class MinMaxLodStrategyTest {
private val strategy = MinMaxLodStrategy()
private val outX = FloatArray(8192)
private val outY = FloatArray(8192)
@Test fun emptyInput_returnsZero() {
assertEquals(0, strategy.decimate(LongArray(0), FloatArray(0), 0, 0L, 1000L, 100, outX, outY))
}
@Test fun zeroPixelWidth_returnsZero() {
assertEquals(0, strategy.decimate(longArrayOf(0L), floatArrayOf(1f), 1, 0L, 1000L, 0, outX, outY))
}
@Test fun zeroWindowMs_returnsZero() {
assertEquals(0, strategy.decimate(longArrayOf(0L), floatArrayOf(1f), 1, 0L, 0L, 100, outX, outY))
}
@Test fun fewerSamplesThanPixels_passThrough() {
val ts = longArrayOf(0L, 500L, 1000L)
val v = floatArrayOf(1f, 2f, 3f)
// windowMs=1001 keeps the 1000L sample inside half-open [0, 1001).
val n = strategy.decimate(ts, v, 3, 0L, 1001L, 100, outX, outY)
assertEquals(3, n)
assertEquals(1f, outY[0]); assertEquals(2f, outY[1]); assertEquals(3f, outY[2])
}
@Test fun moreSamplesThanPixels_atMost2xPixels() {
val n = 1000
val ts = LongArray(n) { it.toLong() }
val v = FloatArray(n) { it.toFloat() }
val pixels = 50
val count = strategy.decimate(ts, v, n, 0L, n.toLong(), pixels, outX, outY)
assertTrue(count <= pixels * 2)
assertTrue(count > 0)
}
@Test fun spikePreserved_singleHugeOutlier() {
// 1000 samples = 1f baseline + single 1000f spike. pixelWidth=10.
val n = 1000
val ts = LongArray(n) { it.toLong() }
val v = FloatArray(n) { 1f }
v[500] = 1000f
val count = strategy.decimate(ts, v, n, 0L, n.toLong(), 10, outX, outY)
val ySet = outY.copyOf(count).toSet()
assertTrue(ySet.contains(1000f), "spike value 1000f must be preserved, got $ySet")
}
// ── Golden-output: hand-computed expected outputs on small inputs ──────────
//
// Replaces the v0.4.0 `parity_vsLegacyLodDecimator_*` tests (LodDecimator deleted in T5).
// The MinMax algorithm is fully specified (per-column min then max, in chronological
// column order), so golden arrays can be hand-derived.
@Test fun goldenOutput_twoColumns_minThenMax() {
// pixelWidth=2, windowMs=200 → col boundary at tRel=100.
// Col 0: ts ∈ [0, 100), values {3, 1, 2} → min=1, max=3
// Col 1: ts ∈ [100, 200], values {4, 6, 5} → min=4, max=6
// n=6, pixelWidth=2 → n > pw → decimation path.
val ts = longArrayOf(0L, 50L, 90L, 100L, 150L, 199L)
val v = floatArrayOf(3f, 1f, 2f, 4f, 6f, 5f)
val count = strategy.decimate(ts, v, 6, 0L, 200L, 2, outX, outY)
// For each non-empty col: emit (xCenter, min) then (xCenter, max).
// Expected: [(0.5, 1f), (0.5, 3f), (1.5, 4f), (1.5, 6f)] → 4 points.
assertEquals(4, count)
assertEquals(0.5f, outX[0]); assertEquals(1f, outY[0])
assertEquals(0.5f, outX[1]); assertEquals(3f, outY[1])
assertEquals(1.5f, outX[2]); assertEquals(4f, outY[2])
assertEquals(1.5f, outX[3]); assertEquals(6f, outY[3])
}
@Test fun upperBoundIsHalfOpen() {
// D3: snapshot semantic is half-open [windowStartMs, windowStartMs+windowMs).
// Sample at tRel == windowMs must be excluded.
// 3 in-window samples + 1 boundary sample at tRel=200 (==windowMs).
val ts = longArrayOf(0L, 50L, 100L, 200L)
val v = floatArrayOf(1f, 2f, 3f, 999f)
val count = strategy.decimate(ts, v, 4, 0L, 200L, 100, outX, outY)
// Boundary sample excluded → n=3 ≤ pixelWidth=100 → pass-through path with 3 points.
assertEquals(3, count)
for (i in 0 until count) {
assertTrue(outY[i] != 999f, "boundary sample at tRel==windowMs must be excluded")
}
}
@Test fun goldenOutput_emptyColumnSkipped() {
// pixelWidth=3, windowMs=300 → col boundaries 0/100/200.
// Col 0: empty. Col 1: ts {150,180} v {10, 20}. Col 2: ts {250} v {5}.
// Wait — single-sample col with n > pw=3 means count must exceed pw=3 to enter
// decimation path. Use more samples: col 1 has 3 samples, col 2 has 1.
// Col 0 empty: ts {} v {}
// Col 1: ts {100,150,199} v {10, 20, 15} → min=10, max=20
// Col 2: ts {250,260,290} v {5, 7, 3} → min=3, max=7
// n=6 > pw=3 → decimation path.
val ts = longArrayOf(100L, 150L, 199L, 250L, 260L, 290L)
val v = floatArrayOf(10f, 20f, 15f, 5f, 7f, 3f)
val count = strategy.decimate(ts, v, 6, 0L, 300L, 3, outX, outY)
// Col 0 skipped; col 1 emits (1.5, 10), (1.5, 20); col 2 emits (2.5, 3), (2.5, 7).
assertEquals(4, count)
assertEquals(1.5f, outX[0]); assertEquals(10f, outY[0])
assertEquals(1.5f, outX[1]); assertEquals(20f, outY[1])
assertEquals(2.5f, outX[2]); assertEquals(3f, outY[2])
assertEquals(2.5f, outX[3]); assertEquals(7f, outY[3])
}
}

View file

@ -0,0 +1,171 @@
package dev.dtrentin.chart.lod
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.sin
import kotlin.random.Random
import kotlin.test.*
import kotlin.time.Duration
import kotlin.time.measureTime
class MinMaxLttbLodStrategyTest {
// Tests run up to 100k samples → enlarge scratch beyond default TieredBuffer.TOTAL_CAPACITY (94_800).
private val strategy = MinMaxLttbLodStrategy(maxCount = 100_000)
private val outX = FloatArray(8192)
private val outY = FloatArray(8192)
@Test fun emptyInput_returnsZero() {
assertEquals(0, strategy.decimate(LongArray(0), FloatArray(0), 0, 0L, 1000L, 100, outX, outY))
}
@Test fun zeroPixelWidth_returnsZero() {
assertEquals(0, strategy.decimate(longArrayOf(0L), floatArrayOf(1f), 1, 0L, 1000L, 0, outX, outY))
}
@Test fun zeroWindowMs_returnsZero() {
assertEquals(0, strategy.decimate(longArrayOf(0L), floatArrayOf(1f), 1, 0L, 0L, 100, outX, outY))
}
@Test fun fewerSamplesThanPixels_passThrough() {
val ts = longArrayOf(0L, 500L, 1000L)
val v = floatArrayOf(1f, 2f, 3f)
// windowMs=1001 keeps the 1000L sample inside half-open [0, 1001).
val n = strategy.decimate(ts, v, 3, 0L, 1001L, 100, outX, outY)
assertEquals(3, n)
assertEquals(1f, outY[0]); assertEquals(2f, outY[1]); assertEquals(3f, outY[2])
}
@Test fun upperBoundIsHalfOpen() {
// D3: snapshot semantic is half-open [windowStartMs, windowStartMs+windowMs).
// Sample at tRel == windowMs must be excluded.
val ts = longArrayOf(0L, 50L, 100L, 200L)
val v = floatArrayOf(1f, 2f, 3f, 999f)
val count = strategy.decimate(ts, v, 4, 0L, 200L, 100, outX, outY)
// Boundary sample excluded → n=3 ≤ pixelWidth=100 → pass-through path with 3 points.
assertEquals(3, count)
for (i in 0 until count) {
assertTrue(outY[i] != 999f, "boundary sample at tRel==windowMs must be excluded")
}
}
@Test fun output_neverExceedsPixelWidth() {
val n = 100_000
val ts = LongArray(n) { it.toLong() }
val v = FloatArray(n) { sin(it.toFloat() / 100f) }
val pixels = 512
val count = strategy.decimate(ts, v, n, 0L, n.toLong(), pixels, outX, outY)
assertTrue(count <= pixels, "count=$count > pixelWidth=$pixels")
assertTrue(count > 0)
}
@Test fun outputX_monotonicallyIncreasing() {
val n = 100_000
val ts = LongArray(n) { it.toLong() }
val v = FloatArray(n) { sin(it.toFloat() / 100f) }
val pixels = 512
val count = strategy.decimate(ts, v, n, 0L, n.toLong(), pixels, outX, outY)
for (i in 1 until count) {
assertTrue(outX[i] >= outX[i - 1], "outX must be non-decreasing at $i: ${outX[i - 1]} -> ${outX[i]}")
}
}
@Test fun spikePreserved_singleTallOutlier() {
// 100k samples baseline 1f + single 1000f spike. pixelWidth=512.
// Preselection guarantees this — bucket containing spike must emit it as max.
val n = 100_000
val ts = LongArray(n) { it.toLong() }
val v = FloatArray(n) { 1f }
v[50_000] = 1000f
val pixels = 512
val count = strategy.decimate(ts, v, n, 0L, n.toLong(), pixels, outX, outY)
val maxOut = outY.copyOf(count).max()
assertEquals(1000f, maxOut, "spike must survive preselection, got max=$maxOut")
}
@Test fun visualFidelity_envelopePreservedVsMinMax() {
// Synthetic sin + noise, 100k samples, n_out=512.
// MinMaxLttb must preserve the global min/max envelope close to pure MIN_MAX.
val n = 100_000
val rng = Random(seed = 42L)
val ts = LongArray(n) { it.toLong() }
val v = FloatArray(n) {
(sin(2.0 * PI * it / 1000.0) * 10.0 + rng.nextDouble(-0.5, 0.5)).toFloat()
}
val pixels = 512
// Pure MIN_MAX baseline.
val minMax = MinMaxLodStrategy(maxCount = 100_000)
val mmX = FloatArray(8192); val mmY = FloatArray(8192)
val mmCount = minMax.decimate(ts, v, n, 0L, n.toLong(), pixels, mmX, mmY)
val mmMin = mmY.copyOf(mmCount).min()
val mmMax = mmY.copyOf(mmCount).max()
// MinMaxLttb under test.
val count = strategy.decimate(ts, v, n, 0L, n.toLong(), pixels, outX, outY)
val outMin = outY.copyOf(count).min()
val outMax = outY.copyOf(count).max()
// Tolerance: 5% of (mmMax - mmMin). Preselection bound says we should see at least
// one of the global min/max per bucket — global envelope should be near-exact.
val tol = (mmMax - mmMin) * 0.05f
assertTrue(abs(outMin - mmMin) <= tol, "min envelope drift: outMin=$outMin vs mmMin=$mmMin tol=$tol")
assertTrue(abs(outMax - mmMax) <= tol, "max envelope drift: outMax=$outMax vs mmMax=$mmMax tol=$tol")
}
@Test fun performance_minMaxLttbNotSlowerThanPureLttb() {
// 100k samples → 512 output. MinMaxLttb should be ≤ pure LTTB time.
// Paper claim is ~10x; acceptance gate is ≥ 1x (≤ pure LTTB time).
val n = 100_000
val ts = LongArray(n) { it.toLong() }
val v = FloatArray(n) { sin(it.toFloat() / 100f) * 10f }
val pixels = 512
val pureLttb = LttbLodStrategy(maxCount = 100_000)
val minMaxLttb = MinMaxLttbLodStrategy(maxCount = 100_000)
// Warm up both.
repeat(3) {
pureLttb.decimate(ts, v, n, 0L, n.toLong(), pixels, outX, outY)
minMaxLttb.decimate(ts, v, n, 0L, n.toLong(), pixels, outX, outY)
}
val pureRuns = LongArray(5)
val mmltRuns = LongArray(5)
for (i in 0 until 5) {
pureRuns[i] = measureTime { pureLttb.decimate(ts, v, n, 0L, n.toLong(), pixels, outX, outY) }.inWholeMicroseconds
mmltRuns[i] = measureTime { minMaxLttb.decimate(ts, v, n, 0L, n.toLong(), pixels, outX, outY) }.inWholeMicroseconds
}
pureRuns.sort(); mmltRuns.sort()
val purMedian = pureRuns[2]
val mmlMedian = mmltRuns[2]
// Acceptance gate per spec: MinMaxLttb ≤ pure LTTB (paper claim 10x; we require ≥1x).
// Give 20% slack for JIT noise on shared infra.
val slackUs = (purMedian * 0.20).toLong()
assertTrue(
mmlMedian <= purMedian + slackUs,
"MinMaxLttb median=$mmlMedian us > pure LTTB median=$purMedian us (slack=$slackUs us)"
)
println("[perf] pureLttb median=${purMedian}us minMaxLttb median=${mmlMedian}us ratio=${purMedian.toDouble() / mmlMedian}x")
}
@Test fun customRatio_ratio2_stillValid() {
val n = 100_000
val ts = LongArray(n) { it.toLong() }
val v = FloatArray(n) { sin(it.toFloat() / 100f) * 10f }
val pixels = 512
val s2 = MinMaxLttbLodStrategy(ratio = 2, maxCount = 100_000)
val count = s2.decimate(ts, v, n, 0L, n.toLong(), pixels, outX, outY)
assertTrue(count <= pixels, "ratio=2 output should still be ≤ pixelWidth, got $count")
assertTrue(count > 0)
// Monotonic X.
for (i in 1 until count) {
assertTrue(outX[i] >= outX[i - 1], "outX must be non-decreasing at $i")
}
}
@Test fun ratio_lessThan1_throws() {
assertFailsWith<IllegalArgumentException> { MinMaxLttbLodStrategy(ratio = 0) }
}
}

View file

@ -0,0 +1,85 @@
package dev.dtrentin.chart.model
import dev.dtrentin.chart.lod.MinMaxLttbLodStrategy
import dev.dtrentin.chart.render.DecimalAxisFormatter
import dev.dtrentin.chart.render.LineSignalRenderer
import dev.dtrentin.chart.render.TimeAxisFormatter
import androidx.compose.ui.graphics.Color
import kotlin.test.*
class ChartConfigTest {
@Test fun ChartConfig_defaultsConstruct() {
// Default no-arg construction must succeed with all sub-config defaults.
val cfg = ChartConfig()
assertEquals(10f, cfg.data.xWindowSeconds)
assertTrue(cfg.data.yRange is YRange.Auto)
assertEquals(T0.FirstSample, cfg.data.t0)
assertEquals(AxisLabelMode.INSIDE, cfg.axis.xLabelMode)
assertEquals(AxisLabelMode.INSIDE, cfg.axis.yLabelMode)
assertEquals(2, cfg.axis.yLabelDecimals)
assertTrue(cfg.axis.showGrid)
assertEquals(ChartTheme(), cfg.render.theme)
assertEquals(FrameRate.Display, cfg.render.frameRate)
}
@Test fun ChartConfig_subConfigsOverride() {
// Override only DataConfig.xWindowSeconds; other fields remain default.
val cfg = ChartConfig(data = DataConfig(xWindowSeconds = 30f))
assertEquals(30f, cfg.data.xWindowSeconds)
// Other DataConfig defaults preserved.
assertTrue(cfg.data.yRange is YRange.Auto)
assertEquals(T0.FirstSample, cfg.data.t0)
// AxisConfig and RenderConfig untouched → equal to defaults.
assertEquals(AxisConfig(), cfg.axis)
// RenderConfig has lodStrategy with fresh allocation each call; compare type only.
assertEquals(FrameRate.Display, cfg.render.frameRate)
assertEquals(ChartTheme(), cfg.render.theme)
}
@Test fun ChartConfig_topLevelAccessors() {
// Convenience accessors delegate to nested configs.
val theme = ChartTheme(backgroundColor = Color.Magenta)
val cfg = ChartConfig(
data = DataConfig(xWindowSeconds = 42f),
render = RenderConfig(theme = theme),
)
assertEquals(42f, cfg.xWindowSeconds)
assertEquals(theme, cfg.theme)
}
@Test fun ChartConfig_equality_isStructural() {
// Two ChartConfig() with default DataConfig + AxisConfig should be structurally equal.
// RenderConfig default contains a fresh MinMaxLttbLodStrategy() — strategies do not
// override equals, so identity differs → render sub-configs are NOT equal.
// Pass an explicit shared RenderConfig to assert structural equality on the rest.
val sharedRender = RenderConfig()
val a = ChartConfig(render = sharedRender)
val b = ChartConfig(render = sharedRender)
assertEquals(a, b)
assertEquals(a.hashCode(), b.hashCode())
}
@Test fun FrameRate_FixedValidation_rejectsZero() {
assertFailsWith<IllegalArgumentException> { FrameRate.Fixed(0) }
}
@Test fun RenderConfig_lodStrategyDefault_isMinMaxLttb() {
val rc = RenderConfig()
assertTrue(
rc.lodStrategy is MinMaxLttbLodStrategy,
"default lodStrategy must be MinMaxLttbLodStrategy, got ${rc.lodStrategy::class}"
)
}
@Test fun AxisConfig_formatterDefaults() {
val ac = AxisConfig()
assertSame(TimeAxisFormatter, ac.xFormatter)
assertEquals(DecimalAxisFormatter(decimals = 2), ac.yFormatter)
}
@Test fun SignalConfig_defaultRenderer_isLineSignalRenderer() {
val sc = SignalConfig(color = Color.Red)
assertSame(LineSignalRenderer, sc.renderer, "default renderer must be LineSignalRenderer singleton")
}
}

View file

@ -0,0 +1,102 @@
package dev.dtrentin.chart.render
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotSame
import kotlin.test.assertSame
import kotlin.test.assertTrue
/**
* Pins behavior of public [AxisFormatter] interface + 4 default implementations
* shipped by v0.5.0 task T4. Wiring into [dev.dtrentin.chart.model.ChartConfig]
* is T5's responsibility these tests cover the formatters in isolation.
*/
class AxisFormatterTest {
// ── TimeAxisFormatter ──────────────────────────────────────────────────────
@Test fun TimeAxisFormatter_matchesNumberFormatTimeSec() {
val samples = doubleArrayOf(0.0, 45.0, 60.0, 3725.0, -65.0)
for (s in samples) {
assertEquals(
NumberFormat.formatTimeSec(s),
TimeAxisFormatter.format(s),
"mismatch at tickValue=$s"
)
}
}
// ── DecimalAxisFormatter ───────────────────────────────────────────────────
@Test fun DecimalAxisFormatter_matchesNumberFormatAxisValue() {
val fmt = DecimalAxisFormatter(decimals = 2)
// 5 samples incl. boundary at 0.01 (fixed-branch boundary in formatAxisValue).
val samples = doubleArrayOf(0.0, 0.005, 0.01, 42.5, 2e6)
for (s in samples) {
assertEquals(
NumberFormat.formatAxisValue(s, 2),
fmt.format(s),
"mismatch at tickValue=$s"
)
}
}
@Test fun DecimalAxisFormatter_defaultDecimalsIsTwo() {
assertEquals(2, DecimalAxisFormatter().decimals)
}
// ── DateTimeAxisFormatter ──────────────────────────────────────────────────
@Test fun DateTimeAxisFormatter_rendersHHmmss() {
val fmt = DateTimeAxisFormatter()
assertEquals("00:00:00", fmt.format(0.0))
assertEquals("01:01:01", fmt.format(3661.0))
// 86401 mod 86400 = 1 → 00:00:01
assertEquals("00:00:01", fmt.format(86401.0))
}
@Test fun DateTimeAxisFormatter_showSecondsFalse_dropsSecondsField() {
val fmt = DateTimeAxisFormatter(showSeconds = false)
// 3725s = 1h 02m 05s → "01:02"
assertEquals("01:02", fmt.format(3725.0))
}
@Test fun DateTimeAxisFormatter_negativeEpoch_wrapsViaKotlinMod() {
// Kotlin Long.mod always returns non-negative result.
// -1L mod 86400 = 86399 → 23:59:59
assertEquals("23:59:59", DateTimeAxisFormatter().format(-1.0))
}
// ── UnitAxisFormatter ──────────────────────────────────────────────────────
@Test fun UnitAxisFormatter_appendsUnit() {
assertEquals("5.20 mV", UnitAxisFormatter("mV").format(5.2))
}
@Test fun UnitAxisFormatter_customSeparatorAndDecimals() {
val fmt = UnitAxisFormatter(unit = "Hz", decimals = 0, separator = "")
assertEquals("100Hz", fmt.format(100.0))
}
@Test fun UnitAxisFormatter_delegatesToFormatAxisValue_forScientific() {
// 2e6 triggers scientific branch in formatAxisValue.
assertEquals("2.00e+06 mV", UnitAxisFormatter("mV").format(2e6))
}
// ── @Immutable stability semantics ─────────────────────────────────────────
@Test fun AxisFormatter_isImmutableStability() {
// object: identity equality
assertSame(TimeAxisFormatter, TimeAxisFormatter)
// data class: value equality
assertEquals(DecimalAxisFormatter(2), DecimalAxisFormatter(2))
assertEquals(DateTimeAxisFormatter(true), DateTimeAxisFormatter(true))
assertEquals(UnitAxisFormatter("mV", 2, " "), UnitAxisFormatter("mV", 2, " "))
// Distinct configs are not equal.
assertNotSame(DecimalAxisFormatter(2), DecimalAxisFormatter(3))
assertTrue(DecimalAxisFormatter(2) != DecimalAxisFormatter(3))
assertTrue(UnitAxisFormatter("mV") != UnitAxisFormatter("Hz"))
}
}

View file

@ -0,0 +1,43 @@
package dev.dtrentin.chart.render
import dev.dtrentin.chart.model.ChartConfig
import dev.dtrentin.chart.model.DataConfig
import dev.dtrentin.chart.model.YRange
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class AxisRendererTest {
// T9: resolveYRange writes into out param (Fixed range).
@Test fun resolveYRange_writesIntoOutArray_fixed() {
val cfg = ChartConfig(data = DataConfig(yRange = YRange.Fixed(min = -5f, max = 12f)))
val out = FloatArray(2)
AxisRenderer.resolveYRange(cfg, dataMin = -100f, dataMax = 100f, outYRange = out)
assertEquals(-5f, out[0])
assertEquals(12f, out[1])
}
// T9: resolveYRange writes into out param (Auto with padding).
@Test fun resolveYRange_writesIntoOutArray_autoWithPadding() {
val cfg = ChartConfig(data = DataConfig(yRange = YRange.Auto(paddingFraction = 0.1f)))
val out = FloatArray(2)
AxisRenderer.resolveYRange(cfg, dataMin = 0f, dataMax = 10f, outYRange = out)
// padding = (10 - 0) * 0.1 = 1 → expect [-1, 11].
assertEquals(-1f, out[0])
assertEquals(11f, out[1])
}
// T9: resolveYRange handles flat data — uses range floor 1e-6 to avoid div-by-zero.
// With paddingFraction 0.1 and floor 1e-6, the effective padding is 1e-7. Below Float
// resolution at value 5f, so out[0] == out[1] == 5f is permitted (downstream renderers
// re-apply their own (yMax-yMin).coerceAtLeast(1e-6f)). Just assert no NaN/Infinity.
@Test fun resolveYRange_flatData_writesFiniteValues() {
val cfg = ChartConfig(data = DataConfig(yRange = YRange.Auto(paddingFraction = 0.1f)))
val out = FloatArray(2)
AxisRenderer.resolveYRange(cfg, dataMin = 5f, dataMax = 5f, outYRange = out)
assertTrue(out[0].isFinite())
assertTrue(out[1].isFinite())
assertTrue(out[1] >= out[0])
}
}

View file

@ -0,0 +1,22 @@
package dev.dtrentin.chart.render
import kotlin.test.Test
import kotlin.test.assertNotSame
import kotlin.test.assertSame
class LineSignalRendererTest {
// T8: Stroke cache reuses same instance across calls with equal strokeWidth.
@Test fun strokeCache_reusesAcrossCallsForSameWidth() {
val a = LineSignalRenderer.internalStrokeForWidth(2f)
val b = LineSignalRenderer.internalStrokeForWidth(2f)
assertSame(a, b)
}
// T8: Different widths get distinct Stroke instances.
@Test fun strokeCache_distinctInstancesForDifferentWidths() {
val a = LineSignalRenderer.internalStrokeForWidth(1.5f)
val b = LineSignalRenderer.internalStrokeForWidth(3f)
assertNotSame(a, b)
}
}

View file

@ -58,10 +58,53 @@ class NumberFormatTest {
@Test fun formatFixed_largeSafeValue() { @Test fun formatFixed_largeSafeValue() {
// Value safely within Long range (Long.MAX_VALUE ~ 9.22e18). // Value safely within Long range (Long.MAX_VALUE ~ 9.22e18).
// See NumberFormat.kt TODO(v0.5.0) — values >= ~1e19 overflow Long cast.
assertEquals("1000000000000000.0", NumberFormat.formatFixed(1e15, 1)) assertEquals("1000000000000000.0", NumberFormat.formatFixed(1e15, 1))
} }
// ── formatFixed overflow guard (D4) ────────────────────────────────────────
@Test fun formatFixed_largeValueDelegatesToScientific_1e19() {
// |1e19| > Long.MAX_VALUE (~9.22e18) → must route to scientific, not emit garbage.
assertEquals("1.00e+19", NumberFormat.formatFixed(1e19, 2))
}
@Test fun formatFixed_largeValueDelegatesToScientific_negative1e19() {
assertEquals("-1.000e+19", NumberFormat.formatFixed(-1e19, 3))
}
@Test fun formatFixed_largeValueDelegatesToScientific_1e20() {
assertEquals("1.00e+20", NumberFormat.formatFixed(1e20, 2))
}
@Test fun formatFixed_largeValueDelegatesToScientific_1e25() {
assertEquals("1.00e+25", NumberFormat.formatFixed(1e25, 2))
}
@Test fun formatFixed_belowOverflow_unchanged() {
// 9.0e18 < Long.MAX_VALUE (~9.22e18) with decimals=0 → factor=1, rounded=9e18 → fixed.
// Assert parses back to within 0.01% of input (Double-to-Long precision near boundary).
val out = NumberFormat.formatFixed(9.0e18, 0)
val parsed = out.toDouble()
assertTrue(parsed.isFinite(), "expected finite, got $out")
assertTrue(
abs(parsed - 9.0e18) / 9.0e18 < 1e-4,
"expected ~9.0e18, got $out"
)
}
@Test fun formatFixed_atOverflowBoundary_routesCleanly() {
// Long.MAX_VALUE.toDouble() = 9.223372036854776e18 — exact overflow boundary.
// Either fixed-clean or scientific is acceptable; must parse back within 1%.
val boundary = Long.MAX_VALUE.toDouble()
val out = NumberFormat.formatFixed(boundary, 0)
val parsed = out.toDouble()
assertTrue(parsed.isFinite(), "expected finite Double, got $out")
assertTrue(
abs(parsed - boundary) / boundary < 0.01,
"expected within 1% of $boundary, got $out (parsed=$parsed)"
)
}
// ── formatScientific ─────────────────────────────────────────────────────── // ── formatScientific ───────────────────────────────────────────────────────
@Test fun formatScientific_zero() { @Test fun formatScientific_zero() {

View file

@ -7,3 +7,12 @@ kotlin.incremental=true
android.useAndroidX=true android.useAndroidX=true
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
# AGP 9.0 compat: bypass new DSL + built-in Kotlin so KMP `com.android.library`
# keeps working alongside `org.jetbrains.kotlin.multiplatform`. Migration to
# `com.android.kotlin.multiplatform.library` is deferred (out of D1 scope).
android.builtInKotlin=false
android.newDsl=false
# Kotlin 2.3 defaults consumer to non-packed KLIBs; Compose-MP 1.11.0 still
# publishes packed klibs for native targets. Disable non-packed consumption
# so apiCheck and native compile resolve Compose-MP variants correctly.
kotlin.internal.klibs.non-packed=false

View file

@ -0,0 +1,12 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
toolchainVersion=21

View file

@ -1,16 +1,15 @@
[versions] [versions]
kotlin = "2.1.0" kotlin = "2.3.21"
compose-multiplatform = "1.8.0" compose-multiplatform = "1.11.0"
coroutines = "1.9.0" coroutines = "1.11.0"
android-gradle = "8.7.3" android-gradle = "9.2.0"
androidx-core = "1.15.0" androidx-core = "1.15.0"
junit5 = "5.11.4" junit5 = "5.11.4"
kotlin-test = "2.1.0" kotlin-test = "2.3.21"
activity-compose = "1.10.1" activity-compose = "1.10.1"
androidx-compose-bom = "2025.01.01" androidx-compose-bom = "2025.01.01"
material3 = "1.3.1" material3 = "1.3.1"
binary-compatibility-validator = "0.16.3" binary-compatibility-validator = "0.16.3"
kotlinx-datetime = "0.6.2"
[libraries] [libraries]
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-multiplatform" } compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-multiplatform" }
@ -18,7 +17,6 @@ compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-mu
compose-ui-graphics = { module = "org.jetbrains.compose.ui:ui-graphics", 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" } compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose-multiplatform" }
coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin-test" } 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-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" }

View file

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME