Compare commits
2 commits
512ca35d8b
...
e827deb806
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e827deb806 | ||
|
|
0e3360e4a0 |
16 changed files with 662 additions and 221 deletions
|
|
@ -10,7 +10,7 @@ android {
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "dev.dtrentin.chart.demo"
|
applicationId = "dev.dtrentin.chart.demo"
|
||||||
minSdk = 26
|
minSdk = 24
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "0.1.0"
|
versionName = "0.1.0"
|
||||||
|
|
@ -23,6 +23,20 @@ android {
|
||||||
|
|
||||||
buildFeatures { compose = true }
|
buildFeatures { compose = true }
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = true // R8 — the perf lever
|
||||||
|
isShrinkResources = true // strip unused resources
|
||||||
|
isDebuggable = false // let ART fully optimize (perf test)
|
||||||
|
// debug key so the release apk installs on a dev device (profiling build, not store build)
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sourceSets["main"].kotlin.srcDirs("src/main/kotlin")
|
sourceSets["main"].kotlin.srcDirs("src/main/kotlin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
12
app/proguard-rules.pro
vendored
Normal file
12
app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# Release R8 rules for :app (profiling build).
|
||||||
|
#
|
||||||
|
# Intentionally minimal. Consumer rules ship with the libraries:
|
||||||
|
# - Jetpack Compose (androidx.compose.*) — bundled consumer R8 rules
|
||||||
|
# - kotlinx-coroutines — bundled consumer R8 rules (keeps ServiceLoader/volatile)
|
||||||
|
# - AGP auto-keeps the manifest entry point (.MainActivity)
|
||||||
|
#
|
||||||
|
# No kotlinx.serialization in this app (chart-realtime does not apply the
|
||||||
|
# serialization plugin), so no @Serializer keep rules are required.
|
||||||
|
#
|
||||||
|
# App-specific keeps are added below ONLY when a build/run failure proves them
|
||||||
|
# necessary. Do NOT blanket-keep.
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
package dev.dtrentin.chart.demo
|
package dev.dtrentin.chart.demo
|
||||||
|
|
||||||
import dev.dtrentin.chart.RealtimeChartState
|
import dev.dtrentin.chart.RealtimeChartState
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Synthetic multi-signal producer. Single coroutine pushes one sample per active signal
|
* Synthetic multi-signal producer. Single coroutine pushes one sample per active signal
|
||||||
|
|
@ -34,18 +36,23 @@ suspend fun CoroutineScope.runSignalGenerator(
|
||||||
|
|
||||||
val tickIntervalMs = (1_000L / sampleRateHz).coerceAtLeast(1L)
|
val tickIntervalMs = (1_000L / sampleRateHz).coerceAtLeast(1L)
|
||||||
val dt = 1f / sampleRateHz.toFloat()
|
val dt = 1f / sampleRateHz.toFloat()
|
||||||
val startMs = System.currentTimeMillis()
|
|
||||||
var t = 0f
|
|
||||||
|
|
||||||
while (isActive) {
|
// Push work off Main: sample generation + state.push run on Dispatchers.Default so they
|
||||||
val ts = startMs + (t * 1000f).toLong()
|
// don't contend with Compose draw on the Main dispatcher. withContext inherits the caller's
|
||||||
for (i in signalNames.indices) {
|
// Job → cancelling the caller (e.g. leaving the composable) cancels this loop (no leak).
|
||||||
// Phase-offset each signal so visually they don't overlap when same waveform.
|
withContext(Dispatchers.Default) {
|
||||||
val v = waveforms[i].sample(t, phaseOffset = i * 0.25f)
|
val startMs = System.currentTimeMillis()
|
||||||
state.push(signalNames[i], ts, v)
|
var t = 0f
|
||||||
onPush()
|
while (isActive) {
|
||||||
|
val ts = startMs + (t * 1000f).toLong()
|
||||||
|
for (i in signalNames.indices) {
|
||||||
|
// Phase-offset each signal so visually they don't overlap when same waveform.
|
||||||
|
val v = waveforms[i].sample(t, phaseOffset = i * 0.25f)
|
||||||
|
state.push(signalNames[i], ts, v)
|
||||||
|
onPush()
|
||||||
|
}
|
||||||
|
t += dt
|
||||||
|
delay(tickIntervalMs)
|
||||||
}
|
}
|
||||||
t += dt
|
|
||||||
delay(tickIntervalMs)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ import dev.dtrentin.chart.RealtimeChart
|
||||||
import dev.dtrentin.chart.RealtimeChartState
|
import dev.dtrentin.chart.RealtimeChartState
|
||||||
import dev.dtrentin.chart.interaction.InteractionConfig
|
import dev.dtrentin.chart.interaction.InteractionConfig
|
||||||
import dev.dtrentin.chart.interaction.rememberChartInteractionState
|
import dev.dtrentin.chart.interaction.rememberChartInteractionState
|
||||||
import dev.dtrentin.chart.lod.MinMaxLodStrategy
|
import dev.dtrentin.chart.lod.MinMaxLttbLodStrategy
|
||||||
import dev.dtrentin.chart.model.AxisConfig
|
import dev.dtrentin.chart.model.AxisConfig
|
||||||
import dev.dtrentin.chart.model.AxisLabelMode
|
import dev.dtrentin.chart.model.AxisLabelMode
|
||||||
import dev.dtrentin.chart.model.ChartConfig
|
import dev.dtrentin.chart.model.ChartConfig
|
||||||
|
|
@ -39,8 +39,10 @@ import dev.dtrentin.chart.render.DecimalAxisFormatter
|
||||||
import dev.dtrentin.chart.render.TimeAxisFormatter
|
import dev.dtrentin.chart.render.TimeAxisFormatter
|
||||||
import kotlin.math.PI
|
import kotlin.math.PI
|
||||||
import kotlin.math.sin
|
import kotlin.math.sin
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
private const val STRESS_SIGNAL_COUNT = 8
|
private const val STRESS_SIGNAL_COUNT = 8
|
||||||
private const val STRESS_SAMPLE_HZ = 200
|
private const val STRESS_SAMPLE_HZ = 200
|
||||||
|
|
@ -58,8 +60,9 @@ private val STRESS_COLORS = listOf(
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stress test: 8 simultaneous signals @ 200 Hz each (1600 pushes/sec total). Uses
|
* Stress test: 8 simultaneous signals @ 200 Hz each (1600 pushes/sec total). Uses
|
||||||
* MinMax LoD (preserves peaks under heavy throughput). 10 s window — 16k samples
|
* MinMaxLTTB LoD (preserves min/max envelope, output capped at pixelWidth). 10 s window —
|
||||||
* visible at once.
|
* 16k samples visible at once. Producer runs on [Dispatchers.Default] to keep sample
|
||||||
|
* generation off the Main dispatcher (away from Compose draw).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun StressTestScreen() {
|
fun StressTestScreen() {
|
||||||
|
|
@ -81,7 +84,7 @@ fun StressTestScreen() {
|
||||||
),
|
),
|
||||||
render = RenderConfig(
|
render = RenderConfig(
|
||||||
theme = chartTheme,
|
theme = chartTheme,
|
||||||
lodStrategy = MinMaxLodStrategy(),
|
lodStrategy = MinMaxLttbLodStrategy(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -96,23 +99,28 @@ fun StressTestScreen() {
|
||||||
SignalConfig(color = STRESS_COLORS[i], strokeWidth = 1.5f),
|
SignalConfig(color = STRESS_COLORS[i], strokeWidth = 1.5f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val tickIntervalMs = (1_000L / STRESS_SAMPLE_HZ).coerceAtLeast(1L)
|
// Push work off Main: generation + state.push run on Dispatchers.Default so they
|
||||||
val startMs = System.currentTimeMillis()
|
// don't contend with Compose draw on the Main dispatcher. withContext inherits the
|
||||||
var n = 0L
|
// effect's Job → leaving the composable cancels this loop (no leak).
|
||||||
val twoPi = 2f * PI.toFloat()
|
withContext(Dispatchers.Default) {
|
||||||
while (isActive) {
|
val tickIntervalMs = (1_000L / STRESS_SAMPLE_HZ).coerceAtLeast(1L)
|
||||||
val ts = startMs + (n * 1000L) / STRESS_SAMPLE_HZ
|
val startMs = System.currentTimeMillis()
|
||||||
val t = n.toFloat() / STRESS_SAMPLE_HZ
|
var n = 0L
|
||||||
for (i in 0 until STRESS_SIGNAL_COUNT) {
|
val twoPi = 2f * PI.toFloat()
|
||||||
val freq = 0.3f + i * 0.2f
|
while (isActive) {
|
||||||
val phase = i * 0.4f
|
val ts = startMs + (n * 1000L) / STRESS_SAMPLE_HZ
|
||||||
val amp = 1f + (i % 3) * 0.3f
|
val t = n.toFloat() / STRESS_SAMPLE_HZ
|
||||||
val v = amp * sin(twoPi * freq * (t + phase))
|
for (i in 0 until STRESS_SIGNAL_COUNT) {
|
||||||
state.push("S${i + 1}", ts, v)
|
val freq = 0.3f + i * 0.2f
|
||||||
pushCounter.increment()
|
val phase = i * 0.4f
|
||||||
|
val amp = 1f + (i % 3) * 0.3f
|
||||||
|
val v = amp * sin(twoPi * freq * (t + phase))
|
||||||
|
state.push("S${i + 1}", ts, v)
|
||||||
|
pushCounter.increment()
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
delay(tickIntervalMs)
|
||||||
}
|
}
|
||||||
n++
|
|
||||||
delay(tickIntervalMs)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ android {
|
||||||
compileSdk = 35
|
compileSdk = 35
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdk = 26
|
minSdk = 24
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@ import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.gestures.detectDragGestures
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
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.geometry.Offset
|
||||||
|
|
@ -27,11 +29,16 @@ 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.AxisRenderer.stabilizeYRange
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders all signals held by [state] on a Canvas. Recomposition is driven by Compose
|
* Renders all signals held by [state] into a [Box] of two stacked Canvas layers: a COLD
|
||||||
* snapshot observation of `state.dataVersion`, so the Canvas redraws only when new data
|
* layer (Y-axis line / grid / labels, redrawn only when the stabilized Y range or size
|
||||||
* arrives (batched per frame by the Compose snapshot system).
|
* changes) and a HOT layer above it (signal polylines, X-axis / grid / time-labels,
|
||||||
|
* crosshair). The hot layer reads `state.dataVersion` INSIDE its draw lambda → draw-phase
|
||||||
|
* invalidation (no recomposition), coalesced to one draw per frame by the Compose snapshot
|
||||||
|
* system; it publishes a tick-stabilized Y range (see [stabilizeYRange]) to the cold layer
|
||||||
|
* so per-frame Y-label layout stops running every frame.
|
||||||
*
|
*
|
||||||
* v0.5.0 wiring:
|
* v0.5.0 wiring:
|
||||||
* - Decimation strategy from `state.config.render.lodStrategy` (default MinMaxLTTB).
|
* - Decimation strategy from `state.config.render.lodStrategy` (default MinMaxLTTB).
|
||||||
|
|
@ -42,7 +49,7 @@ import dev.dtrentin.chart.render.AxisRenderer.resolveYRange
|
||||||
* chart behaves identically to v0.4.0 (read-only).
|
* 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 the container [Box].
|
||||||
* @param xWindowSeconds visible X window in seconds; overrides `state.config.data.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.render.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
|
* @param interaction optional state holder enabling user gestures. Create via
|
||||||
|
|
@ -75,15 +82,32 @@ public fun RealtimeChart(
|
||||||
// T9: zero-alloc Y-range out param. Layout: [0] = yMin, [1] = yMax.
|
// T9: zero-alloc Y-range out param. Layout: [0] = yMin, [1] = yMax.
|
||||||
val yRangeOut = remember { FloatArray(2) }
|
val yRangeOut = remember { FloatArray(2) }
|
||||||
|
|
||||||
|
// ── Cold-layer Y-range state (T-split) ────────────────────────────────────
|
||||||
|
// Stabilized (tick-quantized) Y range published to the COLD canvas. Written ONLY by
|
||||||
|
// the hot draw lambda, ONLY when the quantized bounds actually change. The cold canvas
|
||||||
|
// reads these two states → its Y-label TextMeasurer layout + Y-axis/grid draw run only
|
||||||
|
// on that change (or a resize), NOT every frame. Init NaN → cold skips drawing until
|
||||||
|
// the first hot frame publishes a real range.
|
||||||
|
val stableYMin = remember { mutableFloatStateOf(Float.NaN) }
|
||||||
|
val stableYMax = remember { mutableFloatStateOf(Float.NaN) }
|
||||||
|
// Plain (non-snapshot) mirror of the last-published stabilized range. Lets the hot draw
|
||||||
|
// detect a change WITHOUT reading the Compose state — so writing it invalidates the COLD
|
||||||
|
// draw only, never the hot draw itself (no self-invalidation). Layout: [0]=yMin, [1]=yMax.
|
||||||
|
val lastStable = remember { floatArrayOf(Float.NaN, Float.NaN) }
|
||||||
|
// Zero-alloc scratch for stabilizeYRange output. [0] = yMin, [1] = yMax.
|
||||||
|
val stableRangeOut = remember { FloatArray(2) }
|
||||||
|
|
||||||
// Cross-frame caches read by pointer-input lambdas. Plain LongArray slots (NOT Compose
|
// Cross-frame caches read by pointer-input lambdas. Plain LongArray slots (NOT Compose
|
||||||
// state) — writes inside draw must NOT invalidate composition. Pointer-input lambdas
|
// state) — writes inside draw must NOT invalidate composition. Pointer-input lambdas
|
||||||
// read the most recently-rendered values (1-frame lag is acceptable for gestures).
|
// read the most recently-rendered values (1-frame lag is acceptable for gestures).
|
||||||
// Layout: [0] = latestMs, [1] = windowStartMs, [2] = windowMs.
|
// Layout: [0] = latestMs, [1] = windowStartMs, [2] = windowMs.
|
||||||
val interactionCache = remember { longArrayOf(Long.MIN_VALUE, 0L, 0L) }
|
val interactionCache = remember { longArrayOf(Long.MIN_VALUE, 0L, 0L) }
|
||||||
|
|
||||||
val baseModifier = modifier.background(theme.backgroundColor)
|
// Gestures + background live on the container Box (was the single Canvas). The two
|
||||||
|
// stacked child canvases (cold below, hot above) fill it via matchParentSize().
|
||||||
|
val containerModifier = modifier.background(theme.backgroundColor)
|
||||||
val gestureModifier = if (interaction != null) {
|
val gestureModifier = if (interaction != null) {
|
||||||
baseModifier
|
containerModifier
|
||||||
.pointerInput(interaction) {
|
.pointerInput(interaction) {
|
||||||
detectTransformGestures { _, _, zoom, _ ->
|
detectTransformGestures { _, _, zoom, _ ->
|
||||||
if (zoom != 1f) interaction.applyZoom(zoom, fallbackXWindowSeconds = xWindowSeconds)
|
if (zoom != 1f) interaction.applyZoom(zoom, fallbackXWindowSeconds = xWindowSeconds)
|
||||||
|
|
@ -135,98 +159,156 @@ public fun RealtimeChart(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else baseModifier
|
} else containerModifier
|
||||||
|
|
||||||
Canvas(modifier = gestureModifier) {
|
Box(modifier = gestureModifier) {
|
||||||
val currentVersion = state.dataVersion
|
// ── COLD layer (drawn first → below) ───────────────────────────────────
|
||||||
// Recompose may run when interaction state (crosshair / mode) changes even if
|
// Y-axis line + Y grid + Y labels. Reads ONLY the stabilized Y range + size +
|
||||||
// dataVersion did not — so still draw when interaction is non-null and crosshair
|
// insets — never dataVersion. Re-executes (re-measuring Y labels via TextMeasurer)
|
||||||
// is active (to keep overlay glued to canvas across resize / scroll).
|
// only when the stabilized Y range or the canvas size changes. Eliminates the
|
||||||
val interactionActive = interaction != null &&
|
// per-frame Y-label layout that previously ran inside the single hot draw pass.
|
||||||
(interaction.crosshair != null || interaction.mode !is ViewportMode.Following || interaction.xWindowSecondsOverride > 0f)
|
Canvas(modifier = Modifier.matchParentSize()) {
|
||||||
if (currentVersion == lastRenderedVersion[0] && !interactionActive) return@Canvas
|
val yMin = stableYMin.floatValue
|
||||||
// T9: cached entry array — zero-alloc iteration in steady-state.
|
val yMax = stableYMax.floatValue
|
||||||
val signalsArr = state.signalsArray
|
if (yMin.isNaN() || yMax.isNaN() || yMax <= yMin) return@Canvas
|
||||||
val t0 = state.resolvedT0Ms ?: return@Canvas
|
val chartBottom = size.height - chartBottomInsetPx
|
||||||
if (signalsArr.isEmpty()) return@Canvas
|
drawYAxis(
|
||||||
|
yMin, yMax, theme, textMeasurer,
|
||||||
val effectiveXWindowSec =
|
config.axis.yLabelMode, config.axis.yLabelDecimals,
|
||||||
if (interaction != null && interaction.xWindowSecondsOverride > 0f) interaction.xWindowSecondsOverride
|
chartLeftPx, chartBottom, showGrid = config.axis.showGrid,
|
||||||
else xWindowSeconds
|
)
|
||||||
val windowMs = (effectiveXWindowSec * 1000f).toLong()
|
|
||||||
if (windowMs <= 0L) return@Canvas
|
|
||||||
|
|
||||||
val chartBottom = size.height - chartBottomInsetPx
|
|
||||||
val chartW = size.width - chartLeftPx
|
|
||||||
val pixelWidth = chartW.toInt().coerceAtLeast(1)
|
|
||||||
|
|
||||||
var latestMs = Long.MIN_VALUE
|
|
||||||
for (i in signalsArr.indices) {
|
|
||||||
val ts = signalsArr[i].buffer.latestTimestampMs()
|
|
||||||
if (ts > latestMs) latestMs = ts
|
|
||||||
}
|
}
|
||||||
if (latestMs == Long.MIN_VALUE) return@Canvas
|
|
||||||
|
|
||||||
// Apply interaction viewport offset (History mode shifts window back from live edge).
|
// ── HOT layer (drawn second → above) ───────────────────────────────────
|
||||||
val viewportOffsetMs = interaction?.viewportOffsetMs ?: 0L
|
// Signal polyline + X-axis/grid/time-labels + crosshair. Reads state.dataVersion
|
||||||
val viewportRightMs = latestMs + viewportOffsetMs
|
// → draw-phase invalidation (composition NOT invalidated), coalesced per frame.
|
||||||
val windowStartMs = viewportRightMs - windowMs
|
// X labels stay hot on purpose: in Following mode windowStartMs advances every
|
||||||
|
// frame so X ticks/labels scroll — caching them would freeze the scroll.
|
||||||
|
Canvas(modifier = Modifier.matchParentSize()) {
|
||||||
|
val currentVersion = state.dataVersion
|
||||||
|
// Recompose may run when interaction state (crosshair / mode) changes even if
|
||||||
|
// 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
|
||||||
|
if (signalsArr.isEmpty()) return@Canvas
|
||||||
|
|
||||||
// Publish to pointer-input cache for next-frame gesture handlers.
|
val effectiveXWindowSec =
|
||||||
interactionCache[0] = latestMs
|
if (interaction != null && interaction.xWindowSecondsOverride > 0f) interaction.xWindowSecondsOverride
|
||||||
interactionCache[1] = windowStartMs
|
else xWindowSeconds
|
||||||
interactionCache[2] = windowMs
|
val windowMs = (effectiveXWindowSec * 1000f).toLong()
|
||||||
|
if (windowMs <= 0L) return@Canvas
|
||||||
|
|
||||||
// Single snapshot pass per signal (T11). Per-signal scratch arrays live in SignalEntry.
|
val chartBottom = size.height - chartBottomInsetPx
|
||||||
// Y-range scan and path generation both read the same snapshot — no double-snapshot.
|
val chartW = size.width - chartLeftPx
|
||||||
var dataMin = 0f
|
val pixelWidth = chartW.toInt().coerceAtLeast(1)
|
||||||
var dataMax = 0f
|
|
||||||
var hasData = false
|
var latestMs = Long.MIN_VALUE
|
||||||
for (i in signalsArr.indices) {
|
for (i in signalsArr.indices) {
|
||||||
val entry = signalsArr[i]
|
val ts = signalsArr[i].buffer.latestTimestampMs()
|
||||||
if (!entry.config.visible) { entry.scratchCount = 0; continue }
|
if (ts > latestMs) latestMs = ts
|
||||||
val n = entry.buffer.snapshot(windowStartMs, windowMs, entry.scratchTs, entry.scratchV)
|
}
|
||||||
entry.scratchCount = n
|
if (latestMs == Long.MIN_VALUE) return@Canvas
|
||||||
for (j in 0 until n) {
|
|
||||||
val v = entry.scratchV[j]
|
// Apply interaction viewport offset (History mode shifts window back from live edge).
|
||||||
if (!hasData) { dataMin = v; dataMax = v; hasData = true }
|
val viewportOffsetMs = interaction?.viewportOffsetMs ?: 0L
|
||||||
else {
|
val viewportRightMs = latestMs + viewportOffsetMs
|
||||||
if (v < dataMin) dataMin = v
|
val windowStartMs = viewportRightMs - windowMs
|
||||||
if (v > dataMax) dataMax = v
|
|
||||||
|
// 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.
|
||||||
|
// Y-range scan and path generation both read the same snapshot — no double-snapshot.
|
||||||
|
var dataMin = 0f
|
||||||
|
var dataMax = 0f
|
||||||
|
var hasData = false
|
||||||
|
for (i in signalsArr.indices) {
|
||||||
|
val entry = signalsArr[i]
|
||||||
|
if (!entry.config.visible) { entry.scratchCount = 0; continue }
|
||||||
|
// O(log n) bisect + in-window walk (T2). Does NOT copy the full tier ring per
|
||||||
|
// frame — see TieredBuffer.snapshotWindow / CircularBuffer.copyWindow.
|
||||||
|
val n = entry.buffer.snapshotWindow(windowStartMs, windowMs, entry.scratchTs, entry.scratchV)
|
||||||
|
entry.scratchCount = n
|
||||||
|
for (j in 0 until n) {
|
||||||
|
val v = entry.scratchV[j]
|
||||||
|
if (!hasData) { dataMin = v; dataMax = v; hasData = true }
|
||||||
|
else {
|
||||||
|
if (v < dataMin) dataMin = v
|
||||||
|
if (v > dataMax) dataMax = v
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
if (!hasData) { dataMin = -1f; dataMax = 1f }
|
||||||
if (!hasData) { dataMin = -1f; dataMax = 1f }
|
resolveYRange(config, dataMin, dataMax, yRangeOut)
|
||||||
resolveYRange(config, dataMin, dataMax, yRangeOut)
|
// Stabilize (quantize to the axis-tick grid). SHARED by the signal projection
|
||||||
val yMin = yRangeOut[0]
|
// below AND the cold Y grid/labels, so gridlines and signal stay pixel-aligned.
|
||||||
val yMax = yRangeOut[1]
|
// The hot signal uses THIS frame's freshly-computed stable range (so data never
|
||||||
|
// clips); the cold canvas reads the published Compose state and therefore trails
|
||||||
|
// by at most one frame on the rare transition where the range crosses a tick —
|
||||||
|
// imperceptible (both layers then re-converge on the identical numeric range).
|
||||||
|
stabilizeYRange(yRangeOut[0], yRangeOut[1], stableRangeOut)
|
||||||
|
val yMin = stableRangeOut[0]
|
||||||
|
val yMax = stableRangeOut[1]
|
||||||
|
// Publish to the cold layer ONLY when the quantized bounds change. Compared
|
||||||
|
// against a plain (non-snapshot) mirror so this hot lambda never READS the
|
||||||
|
// Compose state → the write invalidates the COLD draw only, never itself.
|
||||||
|
if (stableRangeOut[0] != lastStable[0] || stableRangeOut[1] != lastStable[1]) {
|
||||||
|
lastStable[0] = stableRangeOut[0]
|
||||||
|
lastStable[1] = stableRangeOut[1]
|
||||||
|
stableYMin.floatValue = stableRangeOut[0]
|
||||||
|
stableYMax.floatValue = stableRangeOut[1]
|
||||||
|
}
|
||||||
|
|
||||||
drawXAxis(windowStartMs, windowMs, theme, textMeasurer, config.axis.xLabelMode, chartLeftPx, chartBottom, t0, showGrid = config.axis.showGrid)
|
drawXAxis(windowStartMs, windowMs, theme, textMeasurer, config.axis.xLabelMode, chartLeftPx, chartBottom, t0, showGrid = config.axis.showGrid)
|
||||||
drawYAxis(yMin, yMax, theme, textMeasurer, config.axis.yLabelMode, config.axis.yLabelDecimals, chartLeftPx, chartBottom, showGrid = config.axis.showGrid)
|
|
||||||
|
|
||||||
for (i in signalsArr.indices) {
|
for (i in signalsArr.indices) {
|
||||||
val entry = signalsArr[i]
|
val entry = signalsArr[i]
|
||||||
// Decimate via configured strategy (outside renderer).
|
// Decimate via configured strategy (outside renderer).
|
||||||
val pairCount = lodStrategy.decimate(
|
val pairCount = lodStrategy.decimate(
|
||||||
timestamps = entry.scratchTs,
|
timestamps = entry.scratchTs,
|
||||||
values = entry.scratchV,
|
values = entry.scratchV,
|
||||||
count = entry.scratchCount,
|
count = entry.scratchCount,
|
||||||
windowStartMs = windowStartMs,
|
windowStartMs = windowStartMs,
|
||||||
windowMs = windowMs,
|
windowMs = windowMs,
|
||||||
pixelWidth = pixelWidth,
|
pixelWidth = pixelWidth,
|
||||||
outX = lodX,
|
outX = lodX,
|
||||||
outY = lodY,
|
outY = lodY,
|
||||||
)
|
)
|
||||||
// Delegate to per-signal renderer with primitive params.
|
// Delegate to per-signal renderer with primitive params.
|
||||||
with(entry.config.renderer) {
|
with(entry.config.renderer) {
|
||||||
drawSignal(
|
drawSignal(
|
||||||
color = entry.config.color,
|
color = entry.config.color,
|
||||||
strokeWidth = entry.config.strokeWidth,
|
strokeWidth = entry.config.strokeWidth,
|
||||||
visible = entry.config.visible,
|
visible = entry.config.visible,
|
||||||
lodX = lodX,
|
lodX = lodX,
|
||||||
lodY = lodY,
|
lodY = lodY,
|
||||||
count = pairCount,
|
count = pairCount,
|
||||||
path = path,
|
path = path,
|
||||||
|
chartLeft = chartLeftPx,
|
||||||
|
chartRight = size.width,
|
||||||
|
chartBottom = chartBottom,
|
||||||
|
yMin = yMin,
|
||||||
|
yMax = yMax,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crosshair overlay (drawn last → above signals + axes). Uses the same
|
||||||
|
// stabilized range as the signal so dot markers land on the polyline.
|
||||||
|
val crosshair = interaction?.crosshair
|
||||||
|
if (crosshair != null) {
|
||||||
|
drawCrosshair(
|
||||||
|
crosshair = crosshair,
|
||||||
|
state = state,
|
||||||
|
theme = theme,
|
||||||
|
textMeasurer = textMeasurer,
|
||||||
chartLeft = chartLeftPx,
|
chartLeft = chartLeftPx,
|
||||||
chartRight = size.width,
|
chartRight = size.width,
|
||||||
chartBottom = chartBottom,
|
chartBottom = chartBottom,
|
||||||
|
|
@ -234,25 +316,9 @@ public fun RealtimeChart(
|
||||||
yMax = yMax,
|
yMax = yMax,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Crosshair overlay (drawn last → above signals + axes).
|
lastRenderedVersion[0] = currentVersion
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,76 @@ internal class CircularBuffer(val capacity: Int) {
|
||||||
return currentSize
|
return currentSize
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy only the samples whose timestamp lies in `[fromMs, toMs)` into the caller's arrays,
|
||||||
|
* appended starting at [outOffset], in chronological (ascending) order. Returns the count
|
||||||
|
* written (0 when empty / window outside data).
|
||||||
|
*
|
||||||
|
* Locates the first in-window sample via O(log n) binary search over the ring's logical
|
||||||
|
* order, then walks ONLY the in-window subrange — NO full-buffer copy (unlike
|
||||||
|
* [snapshot] + linear filter). Respects ring wrap-around via the same physical-index
|
||||||
|
* mapping as [snapshot].
|
||||||
|
*
|
||||||
|
* Bounds match the linear per-tier filter in `TieredBuffer.snapshot`:
|
||||||
|
* - lower bound inclusive (`ts >= fromMs`)
|
||||||
|
* - upper bound exclusive (`ts < toMs`)
|
||||||
|
*
|
||||||
|
* Writing stops early if the caller's arrays fill (`outIdx >= outTimestamps.size`),
|
||||||
|
* mirroring the bounds guard in the linear path.
|
||||||
|
*
|
||||||
|
* Thread safety: single-writer/single-reader. `writeIndex`/`size` are read once (volatile)
|
||||||
|
* for a consistent view — identical contract to [snapshot].
|
||||||
|
*/
|
||||||
|
fun copyWindow(
|
||||||
|
fromMs: Long,
|
||||||
|
toMs: Long,
|
||||||
|
outTimestamps: LongArray,
|
||||||
|
outValues: FloatArray,
|
||||||
|
outOffset: Int,
|
||||||
|
): Int {
|
||||||
|
val currentWrite = writeIndex
|
||||||
|
val currentSize = size.coerceAtMost(capacity)
|
||||||
|
if (currentSize == 0) return 0
|
||||||
|
val startIdx: Long = if (currentSize < capacity) 0L else currentWrite - capacity
|
||||||
|
|
||||||
|
// First logical index with ts >= fromMs. When not wrapped, logical == physical and
|
||||||
|
// timestamps[0, currentSize) is already chronological → reuse the contiguous
|
||||||
|
// bisectStart primitive. When wrapped, bisect over the ring's logical order.
|
||||||
|
val startLogical: Int =
|
||||||
|
if (currentSize < capacity) bisectStart(timestamps, currentSize, fromMs)
|
||||||
|
else bisectStartRing(startIdx, currentSize, fromMs)
|
||||||
|
|
||||||
|
var outIdx = outOffset
|
||||||
|
var i = startLogical
|
||||||
|
while (i < currentSize) {
|
||||||
|
val src = (((startIdx + i) % capacity + capacity) % capacity).toInt()
|
||||||
|
val ts = timestamps[src]
|
||||||
|
if (ts >= toMs) break
|
||||||
|
if (outIdx >= outTimestamps.size) break
|
||||||
|
outTimestamps[outIdx] = ts
|
||||||
|
outValues[outIdx] = values[src]
|
||||||
|
outIdx++
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return outIdx - outOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ring-aware lower-bound bisect over logical indices `[0, count)`: first `i` where
|
||||||
|
* `timestamps[phys(i)] >= targetMs`, else `count`. `phys(i) = (startIdx + i) mod capacity`.
|
||||||
|
* Assumes the ring's logical order is non-decreasing (chronological push). O(log count).
|
||||||
|
*/
|
||||||
|
private fun bisectStartRing(startIdx: Long, count: Int, targetMs: Long): Int {
|
||||||
|
var lo = 0
|
||||||
|
var hi = count
|
||||||
|
while (lo < hi) {
|
||||||
|
val mid = (lo + hi) ushr 1
|
||||||
|
val src = (((startIdx + mid) % capacity + capacity) % capacity).toInt()
|
||||||
|
if (timestamps[src] < targetMs) lo = mid + 1 else hi = mid
|
||||||
|
}
|
||||||
|
return lo
|
||||||
|
}
|
||||||
|
|
||||||
fun latestTimestampMs(): Long {
|
fun latestTimestampMs(): Long {
|
||||||
if (size == 0) return -1L
|
if (size == 0) return -1L
|
||||||
return timestamps[((writeIndex - 1L + capacity) % capacity).toInt()]
|
return timestamps[((writeIndex - 1L + capacity) % capacity).toInt()]
|
||||||
|
|
@ -66,8 +136,8 @@ internal class CircularBuffer(val capacity: Int) {
|
||||||
* - `count` must be `<= ts.size`
|
* - `count` must be `<= ts.size`
|
||||||
* - `count == 0` → returns 0
|
* - `count == 0` → returns 0
|
||||||
*
|
*
|
||||||
* O(log count). Zero-alloc. Used by `TieredBuffer.snapshotWindow` to locate the
|
* O(log count). Zero-alloc. Used by `CircularBuffer.copyWindow` for the non-wrapped ring
|
||||||
* window-start index in each tier's snapshot instead of linear-scanning all n samples.
|
* (logical == physical order) to locate the window-start index without a linear pre-scan.
|
||||||
*/
|
*/
|
||||||
internal fun bisectStart(ts: LongArray, count: Int, targetMs: Long): Int {
|
internal fun bisectStart(ts: LongArray, count: Int, targetMs: Long): Int {
|
||||||
if (count <= 0) return 0
|
if (count <= 0) return 0
|
||||||
|
|
|
||||||
|
|
@ -200,13 +200,16 @@ internal class TieredBuffer {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bisect-based variant of [snapshot]. Returns identical content + ordering for the
|
* Window read used by the draw hot path. Returns content + ordering IDENTICAL to
|
||||||
* same (windowStartMs, windowMs) args, but locates the window-start index in each
|
* [snapshot] for the same (windowStartMs, windowMs) args, but reads each tier via
|
||||||
* tier's chronologically-sorted snapshot via O(log n) bisect instead of an
|
* [CircularBuffer.copyWindow] — an O(log n) bisect to the window start plus a walk over
|
||||||
* O(n) linear pre-scan. Linear walk runs only over the in-window subrange.
|
* ONLY the in-window subrange. Unlike [snapshot] it does NOT copy the full ring into a
|
||||||
|
* scratch array before filtering (which grows to 60k samples/tier0 and dominated the
|
||||||
|
* per-frame cost).
|
||||||
*
|
*
|
||||||
* Output ordering: tier2 oldest first, then tier1, then tier0 newest — same as [snapshot].
|
* Output ordering: tier2 oldest first, then tier1, then tier0 newest — same as [snapshot].
|
||||||
* Tier boundary clamps (tier0BoundaryMs / tier1BoundaryMs) preserved verbatim.
|
* Tier boundary clamps (tier0BoundaryMs / tier1BoundaryMs) and guards preserved verbatim.
|
||||||
|
* Edge inclusion matches [snapshot]: lower bound inclusive, upper bound exclusive.
|
||||||
*
|
*
|
||||||
* Returns 0 on empty buffer or window entirely outside data.
|
* Returns 0 on empty buffer or window entirely outside data.
|
||||||
*/
|
*/
|
||||||
|
|
@ -226,48 +229,21 @@ internal class TieredBuffer {
|
||||||
var outIdx = 0
|
var outIdx = 0
|
||||||
|
|
||||||
if (windowStartMs < tier1BoundaryMs) {
|
if (windowStartMs < tier1BoundaryMs) {
|
||||||
val n2 = tier2.snapshot(t2Ts, t2Vs)
|
|
||||||
// Tier2 records satisfy ts < tier1BoundaryMs (older than tier1 horizon),
|
// Tier2 records satisfy ts < tier1BoundaryMs (older than tier1 horizon),
|
||||||
// so upper clamp is min(windowEndMs, tier1BoundaryMs).
|
// so upper clamp is min(windowEndMs, tier1BoundaryMs).
|
||||||
val tier2UpperExclusive = if (windowEndMs < tier1BoundaryMs) windowEndMs else tier1BoundaryMs
|
val tier2UpperExclusive = if (windowEndMs < tier1BoundaryMs) windowEndMs else tier1BoundaryMs
|
||||||
val start = bisectStart(t2Ts, n2, windowStartMs)
|
outIdx += tier2.copyWindow(windowStartMs, tier2UpperExclusive, outTimestamps, outValues, outIdx)
|
||||||
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) {
|
if (windowStartMs < tier0BoundaryMs && windowEndMs > tier1BoundaryMs) {
|
||||||
val n1 = tier1.snapshot(t1Ts, t1Vs)
|
|
||||||
// Tier1 records satisfy tier1BoundaryMs <= ts < tier0BoundaryMs.
|
// Tier1 records satisfy tier1BoundaryMs <= ts < tier0BoundaryMs.
|
||||||
val tier1Lower = if (windowStartMs > tier1BoundaryMs) windowStartMs else tier1BoundaryMs
|
val tier1Lower = if (windowStartMs > tier1BoundaryMs) windowStartMs else tier1BoundaryMs
|
||||||
val tier1UpperExclusive = if (windowEndMs < tier0BoundaryMs) windowEndMs else tier0BoundaryMs
|
val tier1UpperExclusive = if (windowEndMs < tier0BoundaryMs) windowEndMs else tier0BoundaryMs
|
||||||
val start = bisectStart(t1Ts, n1, tier1Lower)
|
outIdx += tier1.copyWindow(tier1Lower, tier1UpperExclusive, outTimestamps, outValues, outIdx)
|
||||||
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 tier0Start = if (windowStartMs > tier0BoundaryMs) windowStartMs else tier0BoundaryMs
|
||||||
val n0 = tier0.snapshot(t0Ts, t0Vs)
|
outIdx += tier0.copyWindow(tier0Start, windowEndMs, outTimestamps, outValues, outIdx)
|
||||||
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
|
return outIdx
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,8 @@ internal object InverseProjection {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the value of the sample closest to [targetTsMs] in [entry]'s already-populated
|
* 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
|
* scratch arrays. Caller must have invoked `entry.buffer.snapshotWindow(...)` for the
|
||||||
* frame (i.e. `entry.scratchCount`, `entry.scratchTs`, `entry.scratchV` are populated).
|
* current frame (i.e. `entry.scratchCount`, `entry.scratchTs`, `entry.scratchV` are populated).
|
||||||
*
|
*
|
||||||
* Uses binary search on the chronologically-sorted `scratchTs` (snapshot output ordering
|
* Uses binary search on the chronologically-sorted `scratchTs` (snapshot output ordering
|
||||||
* is documented in `TieredBuffer.snapshot`).
|
* is documented in `TieredBuffer.snapshot`).
|
||||||
|
|
|
||||||
|
|
@ -211,4 +211,33 @@ internal object AxisRenderer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quantizes an exact Y range to the axis-tick grid so downstream (cold-layer) Y-label
|
||||||
|
* layout runs only when the tick-aligned bounds actually change — not every frame.
|
||||||
|
*
|
||||||
|
* Expands OUTWARD to a multiple of the range's own nice step (via
|
||||||
|
* [NumberFormat.niceInterval]; [drawYAxis] re-derives the same — equal in-band, never
|
||||||
|
* finer), so:
|
||||||
|
* - the stabilized range always CONTAINS the exact range → a signal projected with it
|
||||||
|
* never clips, and
|
||||||
|
* - signal and Y gridlines/labels share the identical `[yMin, yMax]` → never mutually
|
||||||
|
* misalign (the cold layer draws off the same stabilized bounds).
|
||||||
|
*
|
||||||
|
* Under small frame-to-frame data wobble the result is IDENTICAL (bounds only move when
|
||||||
|
* the data crosses a tick line or the range crosses a niceInterval band), which is what
|
||||||
|
* keeps the cold layer cold. Non-finite / non-positive-range input passes through
|
||||||
|
* unchanged. Writes `out[0] = yMin`, `out[1] = yMax` (caller owns the 2-element array).
|
||||||
|
*/
|
||||||
|
internal fun stabilizeYRange(exactMin: Float, exactMax: Float, out: FloatArray) {
|
||||||
|
val range = (exactMax - exactMin).toDouble()
|
||||||
|
if (!exactMin.isFinite() || !exactMax.isFinite() || range <= 0.0) {
|
||||||
|
out[0] = exactMin
|
||||||
|
out[1] = exactMax
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val step = NumberFormat.niceInterval(range, targetTickCount = 5)
|
||||||
|
out[0] = (floor(exactMin / step) * step).toFloat()
|
||||||
|
out[1] = (ceil(exactMax / step) * step).toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
package dev.dtrentin.chart.render
|
package dev.dtrentin.chart.render
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Paint
|
||||||
|
import androidx.compose.ui.graphics.PaintingStyle
|
||||||
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 androidx.compose.ui.graphics.drawscope.clipRect
|
||||||
|
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default [SignalRenderer] impl: stroked polyline through pre-decimated points.
|
* Default [SignalRenderer] impl: stroked polyline through pre-decimated points.
|
||||||
|
|
@ -15,32 +17,42 @@ import androidx.compose.ui.graphics.drawscope.clipRect
|
||||||
* Implementation is stateless from the caller's perspective — all scratch (Path, FloatArrays)
|
* Implementation is stateless from the caller's perspective — all scratch (Path, FloatArrays)
|
||||||
* is caller-owned and passed in per call.
|
* is caller-owned and passed in per call.
|
||||||
*
|
*
|
||||||
* v0.5.0 T8: a process-wide [Stroke] cache keyed by `strokeWidth` eliminates the per-frame
|
* Anti-aliasing is disabled on the signal stroke. `DrawScope.drawPath` forces AA on with no
|
||||||
* `Stroke(width = ...)` allocation. Cache assumes single-threaded Compose UI access (the
|
* opt-out, which pins Skia to CPU coverage-mask rasterization (aaa_fill_path / blitAntiH). We
|
||||||
* renderer is only ever invoked from the UI thread during Canvas draw). Capped at
|
* therefore draw through a common [Paint] (`isAntiAlias = false`) via [drawIntoCanvas], flipping
|
||||||
* [STROKE_CACHE_MAX] entries (sane upper bound: typical apps use < 8 distinct widths).
|
* Skia to GPU tessellation and removing that CPU cost. Configured stroke width is preserved.
|
||||||
* 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).
|
* A process-wide [Paint] cache keyed by `strokeWidth` eliminates the per-frame `Paint(...)`
|
||||||
|
* allocation. Cache assumes single-threaded Compose UI access (the renderer is only ever
|
||||||
|
* invoked from the UI thread during Canvas draw). Capped at [PAINT_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). Color varies per signal and is mutated on the cached instance per call.
|
||||||
*/
|
*/
|
||||||
public object LineSignalRenderer : SignalRenderer {
|
public object LineSignalRenderer : SignalRenderer {
|
||||||
|
|
||||||
// T8: process-wide stroke cache. Keyed by Float (strokeWidth in px units, as supplied by
|
// Process-wide paint cache. Keyed by Float (strokeWidth in px units, as supplied by
|
||||||
// SignalConfig.strokeWidth). Single-threaded UI access assumption — no synchronization.
|
// SignalConfig.strokeWidth). Single-threaded UI access assumption — no synchronization.
|
||||||
private val strokeCache: HashMap<Float, Stroke> = HashMap(8)
|
private val paintCache: HashMap<Float, Paint> = HashMap(8)
|
||||||
private const val STROKE_CACHE_MAX: Int = 16
|
private const val PAINT_CACHE_MAX: Int = 16
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal test hook. Returns the same [Stroke] instance across calls with equal
|
* Internal test hook. Returns the same [Paint] instance across calls with equal
|
||||||
* [strokeWidth]. Mutates the cache (creates an entry on miss).
|
* [strokeWidth]. Mutates the cache (creates an entry on miss). The returned paint has
|
||||||
|
* `isAntiAlias = false`, `style = Stroke`, and `strokeWidth` set to [strokeWidth].
|
||||||
*/
|
*/
|
||||||
internal fun internalStrokeForWidth(strokeWidth: Float): Stroke = strokeForWidth(strokeWidth)
|
internal fun internalPaintForWidth(strokeWidth: Float): Paint = paintForWidth(strokeWidth)
|
||||||
|
|
||||||
private fun strokeForWidth(strokeWidth: Float): Stroke {
|
private fun paintForWidth(strokeWidth: Float): Paint {
|
||||||
val cached = strokeCache[strokeWidth]
|
val cached = paintCache[strokeWidth]
|
||||||
if (cached != null) return cached
|
if (cached != null) return cached
|
||||||
if (strokeCache.size >= STROKE_CACHE_MAX) strokeCache.clear()
|
if (paintCache.size >= PAINT_CACHE_MAX) paintCache.clear()
|
||||||
val fresh = Stroke(width = strokeWidth)
|
val fresh = Paint().apply {
|
||||||
strokeCache[strokeWidth] = fresh
|
isAntiAlias = false
|
||||||
|
style = PaintingStyle.Stroke
|
||||||
|
this.strokeWidth = strokeWidth
|
||||||
|
}
|
||||||
|
paintCache[strokeWidth] = fresh
|
||||||
return fresh
|
return fresh
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,9 +81,10 @@ public object LineSignalRenderer : SignalRenderer {
|
||||||
for (i in 1 until count) {
|
for (i in 1 until count) {
|
||||||
path.lineTo(chartLeft + lodX[i], chartBottom - ((lodY[i] - yMin) * invY) * chartBottom)
|
path.lineTo(chartLeft + lodX[i], chartBottom - ((lodY[i] - yMin) * invY) * chartBottom)
|
||||||
}
|
}
|
||||||
val stroke = strokeForWidth(strokeWidth)
|
val paint = paintForWidth(strokeWidth)
|
||||||
|
paint.color = color
|
||||||
clipRect(left = chartLeft, top = 0f, right = chartRight, bottom = chartBottom) {
|
clipRect(left = chartLeft, top = 0f, right = chartRight, bottom = chartBottom) {
|
||||||
drawPath(path = path, color = color, style = stroke)
|
drawIntoCanvas { it.drawPath(path, paint) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package dev.dtrentin.chart.buffer
|
package dev.dtrentin.chart.buffer
|
||||||
|
|
||||||
|
import kotlin.random.Random
|
||||||
import kotlin.test.*
|
import kotlin.test.*
|
||||||
|
|
||||||
class CircularBufferTest {
|
class CircularBufferTest {
|
||||||
|
|
@ -199,4 +200,115 @@ class CircularBufferTest {
|
||||||
assertEquals(1002L, ts[cap - 3]); assertEquals(2f, v[cap - 3])
|
assertEquals(1002L, ts[cap - 3]); assertEquals(2f, v[cap - 3])
|
||||||
assertEquals(1001L, ts[cap - 4]); assertEquals(1f, v[cap - 4])
|
assertEquals(1001L, ts[cap - 4]); assertEquals(1f, v[cap - 4])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- T2: copyWindow bisect vs linear-filter equivalence ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Equivalence oracle: full snapshot + chronological in-window filter [fromMs, toMs)
|
||||||
|
* — mirrors the old linear per-tier scan that copyWindow replaces.
|
||||||
|
*/
|
||||||
|
private fun linearWindow(buf: CircularBuffer, fromMs: Long, toMs: Long): Pair<LongArray, FloatArray> {
|
||||||
|
val cap = buf.capacity
|
||||||
|
val ts = LongArray(cap); val v = FloatArray(cap)
|
||||||
|
val n = buf.snapshot(ts, v)
|
||||||
|
val outTs = ArrayList<Long>(); val outV = ArrayList<Float>()
|
||||||
|
for (i in 0 until n) {
|
||||||
|
if (ts[i] >= fromMs && ts[i] < toMs) { outTs.add(ts[i]); outV.add(v[i]) }
|
||||||
|
}
|
||||||
|
return outTs.toLongArray() to outV.toFloatArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertCopyWindowMatchesLinear(buf: CircularBuffer, fromMs: Long, toMs: Long, msg: String) {
|
||||||
|
val (refTs, refV) = linearWindow(buf, fromMs, toMs)
|
||||||
|
val outTs = LongArray(buf.capacity); val outV = FloatArray(buf.capacity)
|
||||||
|
val n = buf.copyWindow(fromMs, toMs, outTs, outV, 0)
|
||||||
|
assertEquals(refTs.size, n, "$msg: count mismatch")
|
||||||
|
for (i in 0 until n) {
|
||||||
|
assertEquals(refTs[i], outTs[i], "$msg: ts mismatch at i=$i")
|
||||||
|
assertEquals(refV[i], outV[i], "$msg: v mismatch at i=$i")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun copyWindow_emptyBuffer_returnsZero() {
|
||||||
|
val buf = CircularBuffer(8)
|
||||||
|
assertEquals(0, buf.copyWindow(0L, 1000L, LongArray(8), FloatArray(8), 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun copyWindow_notWrapped_matchesLinear() {
|
||||||
|
val buf = CircularBuffer(16)
|
||||||
|
for (i in 0 until 10) buf.push(i * 10L, i.toFloat()) // ts 0..90
|
||||||
|
assertCopyWindowMatchesLinear(buf, 20L, 70L, "notWrapped[20,70)")
|
||||||
|
assertCopyWindowMatchesLinear(buf, 0L, 100L, "notWrapped full")
|
||||||
|
assertCopyWindowMatchesLinear(buf, 25L, 66L, "notWrapped off-grid")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun copyWindow_wrapped_matchesLinear() {
|
||||||
|
val cap = 5; val buf = CircularBuffer(cap)
|
||||||
|
for (i in 0..7) buf.push(i * 10L, i.toFloat()) // retained ts {30,40,50,60,70}
|
||||||
|
assertCopyWindowMatchesLinear(buf, 0L, 1000L, "wrapped full")
|
||||||
|
assertCopyWindowMatchesLinear(buf, 40L, 60L, "wrapped [40,60)")
|
||||||
|
assertCopyWindowMatchesLinear(buf, 45L, 65L, "wrapped off-grid")
|
||||||
|
assertCopyWindowMatchesLinear(buf, 0L, 30L, "wrapped before retained")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun copyWindow_windowBeforeAllData_returnsZero() {
|
||||||
|
val buf = CircularBuffer(8)
|
||||||
|
buf.push(1000L, 1f); buf.push(1010L, 2f)
|
||||||
|
assertEquals(0, buf.copyWindow(0L, 500L, LongArray(8), FloatArray(8), 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun copyWindow_windowAfterAllData_returnsZero() {
|
||||||
|
val buf = CircularBuffer(8)
|
||||||
|
buf.push(100L, 1f); buf.push(110L, 2f)
|
||||||
|
assertEquals(0, buf.copyWindow(1000L, 2000L, LongArray(8), FloatArray(8), 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun copyWindow_lowerInclusiveUpperExclusive() {
|
||||||
|
val buf = CircularBuffer(8)
|
||||||
|
for (i in 1..5) buf.push(i * 10L, i.toFloat()) // 10,20,30,40,50
|
||||||
|
val ts = LongArray(8); val v = FloatArray(8)
|
||||||
|
// [20,40): includes 20 (lower inclusive), excludes 40 (upper exclusive).
|
||||||
|
val n = buf.copyWindow(20L, 40L, ts, v, 0)
|
||||||
|
assertEquals(2, n)
|
||||||
|
assertEquals(20L, ts[0]); assertEquals(30L, ts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun copyWindow_singlePointWindow() {
|
||||||
|
val buf = CircularBuffer(8)
|
||||||
|
for (i in 1..5) buf.push(i * 10L, i.toFloat())
|
||||||
|
val ts = LongArray(8); val v = FloatArray(8)
|
||||||
|
// [30,31) → exactly the point at 30.
|
||||||
|
assertEquals(1, buf.copyWindow(30L, 31L, ts, v, 0))
|
||||||
|
assertEquals(30L, ts[0]); assertEquals(3f, v[0])
|
||||||
|
// [30,30) → empty (upper exclusive == lower).
|
||||||
|
assertEquals(0, buf.copyWindow(30L, 30L, ts, v, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun copyWindow_writesAtOffset_preservesPrefixAndReturnsCount() {
|
||||||
|
val buf = CircularBuffer(8)
|
||||||
|
for (i in 1..5) buf.push(i * 10L, i.toFloat()) // 10..50
|
||||||
|
val ts = LongArray(8) { -1L }; val v = FloatArray(8) { -1f }
|
||||||
|
val offset = 3
|
||||||
|
val n = buf.copyWindow(20L, 50L, ts, v, offset) // {20,30,40}
|
||||||
|
assertEquals(3, n)
|
||||||
|
// Prefix untouched.
|
||||||
|
for (i in 0 until offset) { assertEquals(-1L, ts[i]); assertEquals(-1f, v[i]) }
|
||||||
|
// Window appended at offset.
|
||||||
|
assertEquals(20L, ts[offset]); assertEquals(30L, ts[offset + 1]); assertEquals(40L, ts[offset + 2])
|
||||||
|
assertEquals(2f, v[offset]); assertEquals(3f, v[offset + 1]); assertEquals(4f, v[offset + 2])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun copyWindow_randomWindows_wrapped_matchesLinear() {
|
||||||
|
val cap = 64; val buf = CircularBuffer(cap)
|
||||||
|
// Push far more than capacity → heavy wrap. Monotonic ts.
|
||||||
|
val total = 500
|
||||||
|
for (i in 0 until total) buf.push(i * 7L, i.toFloat())
|
||||||
|
val maxTs = (total - 1) * 7L
|
||||||
|
val rnd = Random(0xC0FFEE)
|
||||||
|
repeat(300) {
|
||||||
|
val a = rnd.nextLong(-50L, maxTs + 50L)
|
||||||
|
val span = rnd.nextLong(0L, maxTs + 100L)
|
||||||
|
assertCopyWindowMatchesLinear(buf, a, a + span, "randWrapped[from=$a,span=$span]")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package dev.dtrentin.chart.buffer
|
package dev.dtrentin.chart.buffer
|
||||||
|
|
||||||
|
import kotlin.random.Random
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
|
|
@ -601,4 +602,76 @@ class TieredBufferTest {
|
||||||
assertTrue(ts[i] >= ts[i - 1], "monotonic-non-decreasing ts violated at i=$i")
|
assertTrue(ts[i] >= ts[i - 1], "monotonic-non-decreasing ts violated at i=$i")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- T2: snapshotWindow (ring bisect) equivalence over a WRAPPED tier0 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill tier0 well past its 60k capacity while keeping every sample inside the 5-min
|
||||||
|
* tier0 horizon (step 4ms → 70k samples span 280s < 300s). Forces ring wrap-around
|
||||||
|
* (writeIndex >> capacity) so the bisect must resolve physical wrap. All windows here
|
||||||
|
* land in the tier0-only region, isolating the ring-wrap path.
|
||||||
|
*/
|
||||||
|
private fun filledWrappedTier0(): TieredBuffer {
|
||||||
|
val buf = TieredBuffer()
|
||||||
|
val n = TieredBuffer.TIER0_CAPACITY + 10_000 // 70_000 > capacity → wraps
|
||||||
|
for (i in 0 until n) buf.push(i * 4L, i.toFloat())
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun snapshotWindow_randomWindows_overWrappedTier0_equalsSnapshot() {
|
||||||
|
val buf = filledWrappedTier0()
|
||||||
|
val maxTs = (TieredBuffer.TIER0_CAPACITY + 10_000 - 1) * 4L
|
||||||
|
// Oldest retained tier0 ts after wrap.
|
||||||
|
val minRetained = 10_000L * 4L
|
||||||
|
val tsA = outTs(); val vsA = outVs()
|
||||||
|
val tsB = outTs(); val vsB = outVs()
|
||||||
|
val rnd = Random(0xBADC0DE)
|
||||||
|
repeat(200) {
|
||||||
|
val start = rnd.nextLong(minRetained - 500L, maxTs + 500L)
|
||||||
|
val span = rnd.nextLong(1L, 50_000L)
|
||||||
|
val nA = buf.snapshot(start, span, tsA, vsA)
|
||||||
|
val nB = buf.snapshotWindow(start, span, tsB, vsB)
|
||||||
|
assertSnapshotEquivalent(nA, tsA, vsA, nB, tsB, vsB, "randWrapped[start=$start,span=$span]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun snapshotWindow_windowStraddlingRingWrap_equalsSnapshot() {
|
||||||
|
val buf = filledWrappedTier0()
|
||||||
|
val maxTs = (TieredBuffer.TIER0_CAPACITY + 10_000 - 1) * 4L
|
||||||
|
val minRetained = 10_000L * 4L
|
||||||
|
// Window centred in the retained range — physical wrap point falls inside it.
|
||||||
|
val mid = (minRetained + maxTs) / 2L
|
||||||
|
val start = mid - 30_000L
|
||||||
|
val span = 60_000L
|
||||||
|
val tsA = outTs(); val vsA = outVs()
|
||||||
|
val tsB = outTs(); val vsB = outVs()
|
||||||
|
val nA = buf.snapshot(start, span, tsA, vsA)
|
||||||
|
val nB = buf.snapshotWindow(start, span, tsB, vsB)
|
||||||
|
assertSnapshotEquivalent(nA, tsA, vsA, nB, tsB, vsB, "straddleWrap")
|
||||||
|
assertTrue(nB > 0, "expected samples inside straddle window, got $nB")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun snapshotWindow_singlePointWindow_equalsSnapshot() {
|
||||||
|
val buf = TieredBuffer()
|
||||||
|
for (i in 1..5) buf.push(i * 10L, i.toFloat()) // ts 10..50 in tier0
|
||||||
|
val tsA = outTs(); val vsA = outVs()
|
||||||
|
val tsB = outTs(); val vsB = outVs()
|
||||||
|
// [30,31) → single point at ts=30.
|
||||||
|
val nA = buf.snapshot(30L, 1L, tsA, vsA)
|
||||||
|
val nB = buf.snapshotWindow(30L, 1L, tsB, vsB)
|
||||||
|
assertSnapshotEquivalent(nA, tsA, vsA, nB, tsB, vsB, "singlePoint")
|
||||||
|
assertEquals(1, nB); assertEquals(30L, tsB[0]); assertEquals(3f, vsB[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun snapshotWindow_pointsExactlyOnWindowBounds_equalsSnapshot() {
|
||||||
|
val buf = TieredBuffer()
|
||||||
|
for (i in 1..5) buf.push(i * 10L, i.toFloat()) // 10,20,30,40,50
|
||||||
|
val tsA = outTs(); val vsA = outVs()
|
||||||
|
val tsB = outTs(); val vsB = outVs()
|
||||||
|
// Bounds land exactly on samples: start=20 (inclusive), end=40 (exclusive).
|
||||||
|
val nA = buf.snapshot(20L, 20L, tsA, vsA) // window [20,40)
|
||||||
|
val nB = buf.snapshotWindow(20L, 20L, tsB, vsB)
|
||||||
|
assertSnapshotEquivalent(nA, tsA, vsA, nB, tsB, vsB, "onBounds")
|
||||||
|
assertEquals(2, nB) // {20,30}; 40 excluded, 20 included
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,4 +40,42 @@ class AxisRendererTest {
|
||||||
assertTrue(out[1].isFinite())
|
assertTrue(out[1].isFinite())
|
||||||
assertTrue(out[1] >= out[0])
|
assertTrue(out[1] >= out[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// T-split: stabilizeYRange snaps outward to the tick grid and CONTAINS the exact range.
|
||||||
|
@Test fun stabilizeYRange_snapsToTicks_andContainsExactRange() {
|
||||||
|
val out = FloatArray(2)
|
||||||
|
// range = 9.1 → niceInterval(9.1, 5) = 2 → floor(0.3/2)*2 = 0, ceil(9.4/2)*2 = 10.
|
||||||
|
AxisRenderer.stabilizeYRange(0.3f, 9.4f, out)
|
||||||
|
assertEquals(0f, out[0])
|
||||||
|
assertEquals(10f, out[1])
|
||||||
|
assertTrue(out[0] <= 0.3f && out[1] >= 9.4f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// T-split: small frame-to-frame wobble inside a tick band → IDENTICAL bounds (cold stays cold).
|
||||||
|
@Test fun stabilizeYRange_stableUnderSmallWobble() {
|
||||||
|
val a = FloatArray(2)
|
||||||
|
val b = FloatArray(2)
|
||||||
|
AxisRenderer.stabilizeYRange(0.31f, 9.38f, a)
|
||||||
|
AxisRenderer.stabilizeYRange(0.34f, 9.42f, b)
|
||||||
|
assertEquals(a[0], b[0])
|
||||||
|
assertEquals(a[1], b[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// T-split: empty / non-positive range passes through unchanged (no NaN/Inf).
|
||||||
|
@Test fun stabilizeYRange_passthroughForEmptyRange() {
|
||||||
|
val out = FloatArray(2)
|
||||||
|
AxisRenderer.stabilizeYRange(5f, 5f, out)
|
||||||
|
assertEquals(5f, out[0])
|
||||||
|
assertEquals(5f, out[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// T-split: negative range spanning zero snaps symmetrically outward.
|
||||||
|
@Test fun stabilizeYRange_negativeRange_snapsOutward() {
|
||||||
|
val out = FloatArray(2)
|
||||||
|
// range = 2.4 → niceInterval(2.4, 5) = 0.5 → floor(-1.2/0.5)*0.5 = -1.5, ceil(1.2/0.5)*0.5 = 1.5.
|
||||||
|
AxisRenderer.stabilizeYRange(-1.2f, 1.2f, out)
|
||||||
|
assertEquals(-1.5f, out[0])
|
||||||
|
assertEquals(1.5f, out[1])
|
||||||
|
assertTrue(out[0] <= -1.2f && out[1] >= 1.2f)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
package dev.dtrentin.chart.render
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.PaintingStyle
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNotSame
|
||||||
|
import kotlin.test.assertSame
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lives in `iosTest` (Skiko-backed) rather than `commonTest`: the renderer now builds an
|
||||||
|
* `androidx.compose.ui.graphics.Paint`, which on the Android JVM unit-test host delegates to
|
||||||
|
* the non-mockable `android.graphics.Paint` stub (`Method setAntiAlias ... not mocked`). Skiko
|
||||||
|
* provides a real `Paint` backend, so these assertions run for real here. The renderer itself is
|
||||||
|
* `commonMain` code identical on both platforms.
|
||||||
|
*/
|
||||||
|
class LineSignalRendererTest {
|
||||||
|
|
||||||
|
// Paint cache reuses the same instance across calls with equal strokeWidth (no per-frame alloc).
|
||||||
|
@Test fun paintCache_reusesAcrossCallsForSameWidth() {
|
||||||
|
val a = LineSignalRenderer.internalPaintForWidth(2f)
|
||||||
|
val b = LineSignalRenderer.internalPaintForWidth(2f)
|
||||||
|
assertSame(a, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different widths get distinct Paint instances.
|
||||||
|
@Test fun paintCache_distinctInstancesForDifferentWidths() {
|
||||||
|
val a = LineSignalRenderer.internalPaintForWidth(1.5f)
|
||||||
|
val b = LineSignalRenderer.internalPaintForWidth(3f)
|
||||||
|
assertNotSame(a, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anti-aliasing is off on the cached signal paint (the whole point: skip CPU coverage-mask raster).
|
||||||
|
@Test fun paint_antiAliasDisabled() {
|
||||||
|
val paint = LineSignalRenderer.internalPaintForWidth(2f)
|
||||||
|
assertFalse(paint.isAntiAlias)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configured stroke width is honored and style is Stroke.
|
||||||
|
@Test fun paint_honorsStrokeWidthAndStyle() {
|
||||||
|
val paint = LineSignalRenderer.internalPaintForWidth(4.5f)
|
||||||
|
assertEquals(4.5f, paint.strokeWidth)
|
||||||
|
assertEquals(PaintingStyle.Stroke, paint.style)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue