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