From 0e3360e4a08ec19e6b2409feca378eede20dc7f4 Mon Sep 17 00:00:00 2001 From: Trentin Davide Date: Thu, 23 Jul 2026 11:39:00 +0200 Subject: [PATCH] perf(chart-realtime): cut per-frame render CPU on realtime chart Profiling (Pixel 7 + SM-T819) showed the chart CPU-bound in Skia analytic-AA path fill plus a full-buffer copy on every frame. - LineSignalRenderer: draw the signal polyline with anti-aliasing off, via a cached common Paint + drawIntoCanvas (KMP-safe, no nativeCanvas). Flips Skia from CPU coverage-mask raster to GPU tessellation; configured strokeWidth still honored. Removes the old Stroke cache. - TieredBuffer/CircularBuffer: replace the full ~60k-sample copy+scan in the draw hot path with an O(log n) bisect window read (copyWindow). Output byte-identical (equivalence tests incl. ring-wrap + edges). - RealtimeChart: split the single Canvas into a cold layer (Y-axis line, grid, labels) and a hot layer (signal, X-axis, crosshair) so per-frame TextMeasurer layout stops running every frame. Y range stabilized to the tick grid and shared by both layers (signal never clips). - minSdk 26 -> 24. Renderer unit test moved commonTest -> iosTest (Compose Paint delegates to a non-mockable android.graphics.Paint stub on the JVM host; Skiko backs it for real). Verified on SM-T819 (release): frame time 150ms -> 48ms, ~10 -> ~31 fps at full 8x200 Hz load. Co-Authored-By: Claude Opus 4.8 (1M context) --- chart-realtime/build.gradle.kts | 2 +- .../dev/dtrentin/chart/RealtimeChart.kt | 278 +++++++++++------- .../dtrentin/chart/buffer/CircularBuffer.kt | 74 ++++- .../dev/dtrentin/chart/buffer/TieredBuffer.kt | 46 +-- .../chart/interaction/InverseProjection.kt | 4 +- .../dev/dtrentin/chart/render/AxisRenderer.kt | 29 ++ .../chart/render/LineSignalRenderer.kt | 53 ++-- .../chart/buffer/CircularBufferTest.kt | 112 +++++++ .../dtrentin/chart/buffer/TieredBufferTest.kt | 73 +++++ .../dtrentin/chart/render/AxisRendererTest.kt | 38 +++ .../chart/render/LineSignalRendererTest.kt | 22 -- .../chart/render/LineSignalRendererTest.kt | 45 +++ 12 files changed, 588 insertions(+), 188 deletions(-) delete mode 100644 chart-realtime/src/commonTest/kotlin/dev/dtrentin/chart/render/LineSignalRendererTest.kt create mode 100644 chart-realtime/src/iosTest/kotlin/dev/dtrentin/chart/render/LineSignalRendererTest.kt diff --git a/chart-realtime/build.gradle.kts b/chart-realtime/build.gradle.kts index 5d80c7f..1a080bf 100644 --- a/chart-realtime/build.gradle.kts +++ b/chart-realtime/build.gradle.kts @@ -64,7 +64,7 @@ android { compileSdk = 35 defaultConfig { - minSdk = 26 + minSdk = 24 } compileOptions { diff --git a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChart.kt b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChart.kt index f0fd4f1..e033bba 100644 --- a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChart.kt +++ b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChart.kt @@ -5,7 +5,9 @@ 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.foundation.layout.Box import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier 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.drawYAxis 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 - * snapshot observation of `state.dataVersion`, so the Canvas redraws only when new data - * arrives (batched per frame by the Compose snapshot system). + * Renders all signals held by [state] into a [Box] of two stacked Canvas layers: a COLD + * layer (Y-axis line / grid / labels, redrawn only when the stabilized Y range or size + * 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: * - 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). * * @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 theme visual theme; overrides `state.config.render.theme` at call site. * @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. 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 // 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) + // 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) { - baseModifier + containerModifier .pointerInput(interaction) { detectTransformGestures { _, _, zoom, _ -> if (zoom != 1f) interaction.applyZoom(zoom, fallbackXWindowSeconds = xWindowSeconds) @@ -135,98 +159,156 @@ public fun RealtimeChart( ) } } - } else baseModifier + } else containerModifier - Canvas(modifier = gestureModifier) { - 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 - - val effectiveXWindowSec = - if (interaction != null && interaction.xWindowSecondsOverride > 0f) interaction.xWindowSecondsOverride - 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 + Box(modifier = gestureModifier) { + // ── COLD layer (drawn first → below) ─────────────────────────────────── + // Y-axis line + Y grid + Y labels. Reads ONLY the stabilized Y range + size + + // insets — never dataVersion. Re-executes (re-measuring Y labels via TextMeasurer) + // only when the stabilized Y range or the canvas size changes. Eliminates the + // per-frame Y-label layout that previously ran inside the single hot draw pass. + Canvas(modifier = Modifier.matchParentSize()) { + val yMin = stableYMin.floatValue + val yMax = stableYMax.floatValue + if (yMin.isNaN() || yMax.isNaN() || yMax <= yMin) return@Canvas + val chartBottom = size.height - chartBottomInsetPx + drawYAxis( + yMin, yMax, theme, textMeasurer, + config.axis.yLabelMode, config.axis.yLabelDecimals, + chartLeftPx, chartBottom, showGrid = config.axis.showGrid, + ) } - if (latestMs == Long.MIN_VALUE) return@Canvas - // 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 + // ── HOT layer (drawn second → above) ─────────────────────────────────── + // Signal polyline + X-axis/grid/time-labels + crosshair. Reads state.dataVersion + // → draw-phase invalidation (composition NOT invalidated), coalesced per frame. + // 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. - interactionCache[0] = latestMs - interactionCache[1] = windowStartMs - interactionCache[2] = windowMs + val effectiveXWindowSec = + if (interaction != null && interaction.xWindowSecondsOverride > 0f) interaction.xWindowSecondsOverride + else xWindowSeconds + val windowMs = (effectiveXWindowSec * 1000f).toLong() + if (windowMs <= 0L) return@Canvas - // 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 } - val n = entry.buffer.snapshot(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 + 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). + 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. + // 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 } - resolveYRange(config, dataMin, dataMax, yRangeOut) - val yMin = yRangeOut[0] - val yMax = yRangeOut[1] + if (!hasData) { dataMin = -1f; dataMax = 1f } + resolveYRange(config, dataMin, dataMax, yRangeOut) + // Stabilize (quantize to the axis-tick grid). SHARED by the signal projection + // below AND the cold Y grid/labels, so gridlines and signal stay pixel-aligned. + // 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) - drawYAxis(yMin, yMax, theme, textMeasurer, config.axis.yLabelMode, config.axis.yLabelDecimals, chartLeftPx, chartBottom, showGrid = config.axis.showGrid) + drawXAxis(windowStartMs, windowMs, theme, textMeasurer, config.axis.xLabelMode, chartLeftPx, chartBottom, t0, showGrid = config.axis.showGrid) - for (i in signalsArr.indices) { - val entry = signalsArr[i] - // Decimate via configured strategy (outside renderer). - val pairCount = lodStrategy.decimate( - timestamps = entry.scratchTs, - values = entry.scratchV, - count = entry.scratchCount, - 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, + for (i in signalsArr.indices) { + val entry = signalsArr[i] + // Decimate via configured strategy (outside renderer). + val pairCount = lodStrategy.decimate( + timestamps = entry.scratchTs, + values = entry.scratchV, + count = entry.scratchCount, + 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). 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, chartRight = size.width, chartBottom = chartBottom, @@ -234,25 +316,9 @@ public fun RealtimeChart( 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 } } diff --git a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/buffer/CircularBuffer.kt b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/buffer/CircularBuffer.kt index 1ef3cae..32fd959 100644 --- a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/buffer/CircularBuffer.kt +++ b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/buffer/CircularBuffer.kt @@ -43,6 +43,76 @@ internal class CircularBuffer(val capacity: Int) { 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 { if (size == 0) return -1L return timestamps[((writeIndex - 1L + capacity) % capacity).toInt()] @@ -66,8 +136,8 @@ internal class CircularBuffer(val capacity: Int) { * - `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. + * O(log count). Zero-alloc. Used by `CircularBuffer.copyWindow` for the non-wrapped ring + * (logical == physical order) to locate the window-start index without a linear pre-scan. */ internal fun bisectStart(ts: LongArray, count: Int, targetMs: Long): Int { if (count <= 0) return 0 diff --git a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/buffer/TieredBuffer.kt b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/buffer/TieredBuffer.kt index dc382e2..ea6d7b7 100644 --- a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/buffer/TieredBuffer.kt +++ b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/buffer/TieredBuffer.kt @@ -200,13 +200,16 @@ internal class TieredBuffer { } /** - * 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. + * Window read used by the draw hot path. Returns content + ordering IDENTICAL to + * [snapshot] for the same (windowStartMs, windowMs) args, but reads each tier via + * [CircularBuffer.copyWindow] — an O(log n) bisect to the window start plus a walk over + * 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]. - * 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. */ @@ -226,48 +229,21 @@ internal class TieredBuffer { 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++ - } + outIdx += tier2.copyWindow(windowStartMs, tier2UpperExclusive, outTimestamps, outValues, outIdx) } 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++ - } + outIdx += tier1.copyWindow(tier1Lower, tier1UpperExclusive, outTimestamps, outValues, outIdx) } 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++ - } + outIdx += tier0.copyWindow(tier0Start, windowEndMs, outTimestamps, outValues, outIdx) return outIdx } diff --git a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/interaction/InverseProjection.kt b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/interaction/InverseProjection.kt index c0bbc03..4379274 100644 --- a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/interaction/InverseProjection.kt +++ b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/interaction/InverseProjection.kt @@ -33,8 +33,8 @@ internal object InverseProjection { /** * 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). + * scratch arrays. Caller must have invoked `entry.buffer.snapshotWindow(...)` 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`). diff --git a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/render/AxisRenderer.kt b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/render/AxisRenderer.kt index 6dbab22..0369472 100644 --- a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/render/AxisRenderer.kt +++ b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/render/AxisRenderer.kt @@ -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() + } + } diff --git a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/render/LineSignalRenderer.kt b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/render/LineSignalRenderer.kt index c6cb07f..a1d4178 100644 --- a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/render/LineSignalRenderer.kt +++ b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/render/LineSignalRenderer.kt @@ -1,10 +1,12 @@ package dev.dtrentin.chart.render 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.drawscope.DrawScope -import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas /** * 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) * 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). + * Anti-aliasing is disabled on the signal stroke. `DrawScope.drawPath` forces AA on with no + * opt-out, which pins Skia to CPU coverage-mask rasterization (aaa_fill_path / blitAntiH). We + * therefore draw through a common [Paint] (`isAntiAlias = false`) via [drawIntoCanvas], flipping + * Skia to GPU tessellation and removing that CPU cost. Configured stroke width is preserved. + * + * 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 { - // 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. - private val strokeCache: HashMap = HashMap(8) - private const val STROKE_CACHE_MAX: Int = 16 + private val paintCache: HashMap = HashMap(8) + private const val PAINT_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 test hook. Returns the same [Paint] instance across calls with equal + * [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 { - val cached = strokeCache[strokeWidth] + private fun paintForWidth(strokeWidth: Float): Paint { + val cached = paintCache[strokeWidth] if (cached != null) return cached - if (strokeCache.size >= STROKE_CACHE_MAX) strokeCache.clear() - val fresh = Stroke(width = strokeWidth) - strokeCache[strokeWidth] = fresh + if (paintCache.size >= PAINT_CACHE_MAX) paintCache.clear() + val fresh = Paint().apply { + isAntiAlias = false + style = PaintingStyle.Stroke + this.strokeWidth = strokeWidth + } + paintCache[strokeWidth] = fresh return fresh } @@ -69,9 +81,10 @@ public object LineSignalRenderer : SignalRenderer { for (i in 1 until count) { 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) { - drawPath(path = path, color = color, style = stroke) + drawIntoCanvas { it.drawPath(path, paint) } } } } diff --git a/chart-realtime/src/commonTest/kotlin/dev/dtrentin/chart/buffer/CircularBufferTest.kt b/chart-realtime/src/commonTest/kotlin/dev/dtrentin/chart/buffer/CircularBufferTest.kt index 3e23f31..f161b1f 100644 --- a/chart-realtime/src/commonTest/kotlin/dev/dtrentin/chart/buffer/CircularBufferTest.kt +++ b/chart-realtime/src/commonTest/kotlin/dev/dtrentin/chart/buffer/CircularBufferTest.kt @@ -1,5 +1,6 @@ package dev.dtrentin.chart.buffer +import kotlin.random.Random import kotlin.test.* class CircularBufferTest { @@ -199,4 +200,115 @@ class CircularBufferTest { assertEquals(1002L, ts[cap - 3]); assertEquals(2f, v[cap - 3]) 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 { + val cap = buf.capacity + val ts = LongArray(cap); val v = FloatArray(cap) + val n = buf.snapshot(ts, v) + val outTs = ArrayList(); val outV = ArrayList() + 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]") + } + } } diff --git a/chart-realtime/src/commonTest/kotlin/dev/dtrentin/chart/buffer/TieredBufferTest.kt b/chart-realtime/src/commonTest/kotlin/dev/dtrentin/chart/buffer/TieredBufferTest.kt index 8fb49f0..b879de0 100644 --- a/chart-realtime/src/commonTest/kotlin/dev/dtrentin/chart/buffer/TieredBufferTest.kt +++ b/chart-realtime/src/commonTest/kotlin/dev/dtrentin/chart/buffer/TieredBufferTest.kt @@ -1,5 +1,6 @@ package dev.dtrentin.chart.buffer +import kotlin.random.Random import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -601,4 +602,76 @@ class TieredBufferTest { 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 + } } diff --git a/chart-realtime/src/commonTest/kotlin/dev/dtrentin/chart/render/AxisRendererTest.kt b/chart-realtime/src/commonTest/kotlin/dev/dtrentin/chart/render/AxisRendererTest.kt index a3f066d..bcc83df 100644 --- a/chart-realtime/src/commonTest/kotlin/dev/dtrentin/chart/render/AxisRendererTest.kt +++ b/chart-realtime/src/commonTest/kotlin/dev/dtrentin/chart/render/AxisRendererTest.kt @@ -40,4 +40,42 @@ class AxisRendererTest { assertTrue(out[1].isFinite()) 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) + } } diff --git a/chart-realtime/src/commonTest/kotlin/dev/dtrentin/chart/render/LineSignalRendererTest.kt b/chart-realtime/src/commonTest/kotlin/dev/dtrentin/chart/render/LineSignalRendererTest.kt deleted file mode 100644 index 09f423c..0000000 --- a/chart-realtime/src/commonTest/kotlin/dev/dtrentin/chart/render/LineSignalRendererTest.kt +++ /dev/null @@ -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) - } -} diff --git a/chart-realtime/src/iosTest/kotlin/dev/dtrentin/chart/render/LineSignalRendererTest.kt b/chart-realtime/src/iosTest/kotlin/dev/dtrentin/chart/render/LineSignalRendererTest.kt new file mode 100644 index 0000000..8fc9265 --- /dev/null +++ b/chart-realtime/src/iosTest/kotlin/dev/dtrentin/chart/render/LineSignalRendererTest.kt @@ -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) + } +}