Pith - kiln
kiln/app/src/main/java/com/vgmlr/kiln/KilnAccountCharts.kt [7.5 kb]
Modified: 18:59:25 154 026 (20 Aug 026)
3 Days Ago
package com.vgmlr.kiln

import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import java.time.LocalDate
import java.time.YearMonth
import java.time.format.TextStyle as DateTextStyle
import java.time.temporal.ChronoUnit
import java.util.Locale
import kotlin.math.ceil

private val CHART_PAD = PaddingValues(start = 6.dp, top = 28.dp, end = 22.dp, bottom = 12.dp)
private val CARD_GAP = 20.dp

private val Y_GUTTER = 30.dp
private val X_GUTTER = 20.dp
private const val MAX_X_LABELS = 6

private val GridLine = Color.White.copy(alpha = 0.07f)
private val AxisLine = Color.White.copy(alpha = 0.20f)
private val LabelFg = Color.White.copy(alpha = 0.45f)

private val CycleHue = KilnGradientPalette.colorFor(120f)
private val PreHue = KilnGradientPalette.colorFor(30f)
private val PeriodHue = KilnGradientPalette.colorFor(0f)

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun KilnAccountCharts(vm: KilnViewModel, onBack: () -> Unit) {
    val records by vm.records.collectAsState()
    val buttons by vm.buttons.collectAsState()

    val roles = remember(buttons) { KilnButtonSet(buttons) }
    val starts = remember(records, roles) { KilnCyclePredictor.periodStarts(records, roles) }
    val cycle = remember(starts) { KilnCyclePredictor.avgCycleLength(starts) }
    val hueOf = remember(records, roles, starts, cycle) {
        KilnGradientPalette.lookup(records, roles, starts, cycle)
    }

    val cycles = remember(starts) { KilnCyclePredictor.cycleSeries(starts) }
    val pre = remember(starts, cycle, hueOf) {
        KilnDayStats.preMenstrualSeries(starts, cycle, hueOf)
    }
    val menses = remember(records, roles) { KilnCyclePredictor.periodRuns(records, roles) }

    Scaffold(containerColor = CBg, topBar = {
        TopAppBar(
            title = { Text("Charts", color = TopBarFg, fontSize = 18.sp) },
            colors = TopAppBarDefaults.topAppBarColors(containerColor = TopBarBg),
            navigationIcon = {
                IconButton(onClick = onBack) {
                    Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", tint = TopBarFg)
                }
            }
        )
    }) { pad ->
        Column(
            Modifier.fillMaxSize().background(CBg)
                .padding(pad)
                .padding(horizontal = 16.dp)
        ) {
            Spacer(Modifier.height(20.dp))
            ChartSection("Cycles", cycles, CycleHue)
            ChartSection("Pre-Menstruations", pre, PreHue)
            ChartSection("Menstruations", menses, PeriodHue)
        }
    }
}

@Composable
private fun ColumnScope.ChartSection(
    title: String,
    points: List<Pair<LocalDate, Int>>,
    color: Color
) {
    WgSectionLabel(title)
    WgCard(Modifier.weight(1f)) { LineChart(points, color, Modifier.fillMaxSize()) }
    Spacer(Modifier.height(CARD_GAP))
}

@Composable
private fun LineChart(
    points: List<Pair<LocalDate, Int>>,
    color: Color,
    modifier: Modifier = Modifier
) {
    if (points.isEmpty()) {
        Box(modifier.padding(CHART_PAD), contentAlignment = Alignment.Center) {
            Text("NO DATA", color = CMuted, fontSize = 11.sp, letterSpacing = 1.4.sp)
        }
        return
    }

    val pts = remember(points) { points.sortedBy { it.first } }
    val scale = remember(pts) { scaleFor(pts.map { it.second }) }
    val ticks = remember(pts) { monthTicks(pts.first().first, pts.last().first) }
    val measurer = rememberTextMeasurer()
    val labelStyle = TextStyle(
        color = LabelFg,
        fontSize = 10.sp,
        letterSpacing = 0.6.sp,
        fontFamily = FontFamily.Monospace
    )

    Canvas(modifier.padding(CHART_PAD)) {
        val left = Y_GUTTER.toPx()
        val right = size.width
        val bottom = size.height - X_GUTTER.toPx()
        val plotW = right - left
        val hair = 1.dp.toPx()
        
        val minX = YearMonth.from(pts.first().first).atDay(1).toEpochDay()
        val maxX = YearMonth.from(pts.last().first).atEndOfMonth().toEpochDay()
        val spanX = (maxX - minX).coerceAtLeast(1L).toFloat()
        val spanY = (scale.max - scale.min).toFloat()

        fun xOf(d: LocalDate) = left + plotW * ((d.toEpochDay() - minX) / spanX)
        fun yOf(v: Int) = bottom - bottom * ((v - scale.min) / spanY)

        var t = scale.min
        while (t <= scale.max) {
            val y = yOf(t)
            drawLine(GridLine, Offset(left, y), Offset(right, y), hair)
            val lay = measurer.measure(AnnotatedString("$t"), labelStyle)
            drawText(
                lay,
                topLeft = Offset(left - 6.dp.toPx() - lay.size.width, y - lay.size.height / 2f)
            )
            t += scale.step
        }

        for (m in ticks) {
            val x = xOf(m.atDay(1))
            drawLine(GridLine, Offset(x, 0f), Offset(x, bottom), hair)
            val lay = measurer.measure(AnnotatedString(monthLabel(m)), labelStyle)
            val maxLx = (right - lay.size.width).coerceAtLeast(0f)
            val lx = (x - lay.size.width / 2f).coerceIn(0f, maxLx)
            drawText(lay, topLeft = Offset(lx, bottom + 5.dp.toPx()))
        }

        drawLine(AxisLine, Offset(left, 0f), Offset(left, bottom), hair)
        drawLine(AxisLine, Offset(left, bottom), Offset(right, bottom), hair)

        val path = Path()
        pts.forEachIndexed { i, (d, v) ->
            val x = xOf(d)
            val y = yOf(v)
            if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
        }
        drawPath(
            path,
            color,
            style = Stroke(2.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round)
        )
        pts.forEach { (d, v) -> drawCircle(color, 3.dp.toPx(), Offset(xOf(d), yOf(v))) }
    }
}

private data class Scale(val min: Int, val max: Int, val step: Int)

private fun scaleFor(values: List<Int>): Scale {
    val lo = (values.min() - 1).coerceAtLeast(0)
    val span = (values.max() + 1 - lo).coerceAtLeast(2)
    val step = ceil(span / 4f).toInt().coerceAtLeast(1)
    return Scale(lo, lo + step * ceil(span / step.toFloat()).toInt(), step)
}

private fun monthTicks(first: LocalDate, last: LocalDate): List<YearMonth> {
    val a = YearMonth.from(first)
    val b = YearMonth.from(last)
    val n = ChronoUnit.MONTHS.between(a, b).toInt() + 1
    val step = ceil(n / MAX_X_LABELS.toFloat()).toInt().coerceAtLeast(1)
    return (0 until n step step).map { a.plusMonths(it.toLong()) }
}

private fun monthLabel(m: YearMonth): String {
    val name = m.month.getDisplayName(DateTextStyle.SHORT, Locale.getDefault())
        .uppercase(Locale.getDefault()).take(3)
    return if (m.monthValue == 1) "$name ${m.year % 100}" else name
}
Updates
Kerf - Android 157.026
Kiln - Android 157.026
Wedge - Android 156.026
Whittle - Linux 155.026
Grit - Firefox/Chrome 154.026

Menu
Calendar
Project Tin (024/029)
Miter
RSS Feed
User Avatar
@vgmlr
=SUM(parts)
0.00255
257,134 (+388)