package dev.dtrentin.chart import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextMeasurer import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.dp import dev.dtrentin.chart.buffer.TieredBuffer import dev.dtrentin.chart.interaction.ChartInteractionState import dev.dtrentin.chart.interaction.CrosshairState import dev.dtrentin.chart.interaction.InverseProjection import dev.dtrentin.chart.interaction.SignalValueAt import dev.dtrentin.chart.interaction.ViewportMode import dev.dtrentin.chart.model.AxisLabelMode import dev.dtrentin.chart.model.ChartTheme import dev.dtrentin.chart.render.AxisRenderer.drawXAxis import dev.dtrentin.chart.render.AxisRenderer.drawYAxis import dev.dtrentin.chart.render.AxisRenderer.resolveYRange /** * 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). * * v0.5.0 wiring: * - Decimation strategy from `state.config.render.lodStrategy` (default MinMaxLTTB). * - Per-signal renderer from `signal.renderer` (default LineSignalRenderer singleton). * - Decimation runs OUTSIDE the renderer and produces pre-projected `(lodX, lodY, count)` * passed by primitive to `SignalRenderer.drawSignal`. * - Optional [interaction] enables pinch-zoom / drag-pan / tap-crosshair. When null, * chart behaves identically to v0.4.0 (read-only). * * @param state holds all signal data and config. * @param modifier applied to Canvas. * @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 * [dev.dtrentin.chart.interaction.rememberChartInteractionState]. */ @Composable public fun RealtimeChart( state: RealtimeChartState, modifier: Modifier = Modifier, xWindowSeconds: Float = state.config.data.xWindowSeconds, theme: ChartTheme = state.config.render.theme, interaction: ChartInteractionState? = null, ) { val config = state.config val lodStrategy = config.render.lodStrategy val textMeasurer = rememberTextMeasurer() val density = LocalDensity.current val chartLeftPx = remember(config.axis.yLabelMode) { if (config.axis.yLabelMode == AxisLabelMode.BESIDE) with(density) { 52.dp.toPx() } else 0f } val chartBottomInsetPx = remember(config.axis.xLabelMode) { if (config.axis.xLabelMode == AxisLabelMode.BESIDE) with(density) { 20.dp.toPx() } else 0f } // Plain mutable Long slot — written inside draw lambda. Not a Compose state, so the // write does NOT invalidate the composition (avoids self-recomposition loop). val lastRenderedVersion = remember { longArrayOf(-1L) } val lodX = remember { FloatArray(TieredBuffer.TOTAL_CAPACITY) } val lodY = remember { FloatArray(TieredBuffer.TOTAL_CAPACITY) } val path = remember { Path() } // T9: zero-alloc Y-range out param. Layout: [0] = yMin, [1] = yMax. val yRangeOut = 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) val gestureModifier = if (interaction != null) { baseModifier .pointerInput(interaction) { detectTransformGestures { _, _, zoom, _ -> if (zoom != 1f) interaction.applyZoom(zoom, fallbackXWindowSeconds = xWindowSeconds) } } .pointerInput(interaction) { detectDragGestures { change, dragAmount -> val winMs = interactionCache[2] val latestMs = interactionCache[0] if (winMs <= 0L || latestMs == Long.MIN_VALUE) return@detectDragGestures val chartW = (size.width.toFloat() - chartLeftPx).coerceAtLeast(1f) // Drag right (positive dragAmount.x) → look earlier → negative delta. val deltaMs = -((dragAmount.x / chartW) * winMs).toLong() interaction.applyPan(deltaMs, latestMs) change.consume() } } .pointerInput(interaction) { detectTapGestures { offset -> if (interaction.crosshair != null) { interaction.toggleCrosshair(null) return@detectTapGestures } val winMs = interactionCache[2] val winStartMs = interactionCache[1] if (winMs <= 0L) return@detectTapGestures val signals = state.signals if (signals.isEmpty()) return@detectTapGestures val tsAtPixel = InverseProjection.pixelXToTimestampMs( pixelX = offset.x, chartLeft = chartLeftPx, chartRight = size.width.toFloat(), windowStartMs = winStartMs, windowMs = winMs, ) // Build list once; avoid per-frame Map alloc. val values = ArrayList(signals.size) for ((name, entry) in signals) { if (!entry.config.visible) continue val v = InverseProjection.nearestSampleValue(entry, tsAtPixel) values.add(SignalValueAt(name, v)) } interaction.toggleCrosshair( CrosshairState( pixelX = offset.x, timestampMs = tsAtPixel, signalValues = values, ) ) } } } else baseModifier Canvas(modifier = gestureModifier) { val currentVersion = state.dataVersion // 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 } 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 } 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 } } } if (!hasData) { dataMin = -1f; dataMax = 1f } resolveYRange(config, dataMin, dataMax, yRangeOut) val yMin = yRangeOut[0] val yMax = yRangeOut[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) 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). 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 } } /** * Draw vertical guide line at `crosshair.pixelX`, per-signal dot markers at the inverse- * projected timestamp, and a top-right readout box with timestamp + per-signal values. * * Stateless / zero-buffer-access — reads `entry.scratch*` already populated by the * surrounding draw pass + per-signal values already resolved in [CrosshairState.signalValues]. */ private fun DrawScope.drawCrosshair( crosshair: CrosshairState, state: RealtimeChartState, theme: ChartTheme, textMeasurer: TextMeasurer, chartLeft: Float, chartRight: Float, chartBottom: Float, yMin: Float, yMax: Float, ) { val px = crosshair.pixelX.coerceIn(chartLeft, chartRight) // Vertical guide drawLine( color = theme.axisColor, start = Offset(px, 0f), end = Offset(px, chartBottom), strokeWidth = theme.strokeWidth, ) val yRange = (yMax - yMin).coerceAtLeast(1e-6f) val invY = 1f / yRange // Per-signal dots val signals = state.signals for (sv in crosshair.signalValues) { if (sv.value.isNaN()) continue val entry = signals[sv.signalName] ?: continue val yPx = chartBottom - ((sv.value - yMin) * invY) * chartBottom drawCircle( color = entry.config.color, radius = 4f, center = Offset(px, yPx), ) } }