package com.vgmlr.kiln
import androidx.compose.ui.graphics.Color
import java.time.LocalDate
import java.time.temporal.ChronoUnit
import kotlin.math.roundToInt
object KilnGradientPalette {
const val PRE_MENSTRUAL_BUCKET = 2
const val GREEN_PEAK = 7
private const val PMS_EDGE_LO = 40f
private const val PMS_EDGE_HI = 45f
fun hueFor(date: LocalDate, lastStart: LocalDate?, cycle: Int, moodShift: Float = 0f): Float {
if (lastStart == null) return 120f
val phase = Math.floorMod(
ChronoUnit.DAYS.between(lastStart, date), cycle.toLong()
).toFloat()
val greenPeak = 7f
val redStart = (cycle - 6f).coerceAtLeast(greenPeak)
val redEnd = cycle - 1f
val base = when {
phase in greenPeak..redStart ->
120f * (1f - (phase - greenPeak) / (redStart - greenPeak))
phase > redStart && phase < redEnd -> 0f
else -> {
val fromRed = if (phase >= redEnd) phase - redEnd else phase + (cycle - redEnd)
120f * (fromRed / ((cycle - redEnd) + greenPeak))
}
}
return (base + moodShift).coerceIn(0f, 120f)
}
fun colorFor(hue: Float): Color = Color.hsv(hue, 0.50f, 0.95f)
fun bucket(hue: Float): Int = ((hue / 120f) * 7f).roundToInt().coerceIn(0, 7)
fun isPreMenstrual(hue: Float): Boolean = bucket(hue) <= PRE_MENSTRUAL_BUCKET
fun lookup(
records: List<KilnDayRecord>,
buttons: KilnButtonSet,
starts: List<LocalDate>,
cycle: Int,
today: LocalDate = LocalDate.now()
): (LocalDate) -> Float {
if (starts.isEmpty()) return { 120f }
val model = KilnMoodInfluence.model(records, buttons, starts, cycle)
return { date -> hueAt(date, starts, cycle, today, model) }
}
fun baseLead(cycle: Int): Int {
val anchor = LocalDate.ofEpochDay(0)
return (GREEN_PEAK until cycle).count {
isPreMenstrual(hueFor(anchor.plusDays(it.toLong()), anchor, cycle))
}
}
private fun hueAt(
date: LocalDate,
starts: List<LocalDate>,
cycle: Int,
today: LocalDate,
model: KilnPmsModel
): Float {
val i = starts.indexOfLast { !it.isAfter(date) }
if (i < 0) {
val back = ChronoUnit.DAYS.between(date, starts[0])
val s = starts[0].minusDays((back + cycle - 1) / cycle * cycle)
return shape(ChronoUnit.DAYS.between(s, date), cycle, model.lead)
}
val a = starts[i]
val b = starts.getOrNull(i + 1)
if (b != null) {
val len = ChronoUnit.DAYS.between(a, b)
if (len in KilnCyclePredictor.PLAUSIBLE) {
return shape(ChronoUnit.DAYS.between(a, date), len.toInt(), model.leadBefore(b))
}
}
val e = date.minusDays(overdueHold(starts, cycle, today, a, date))
return shape(Math.floorMod(ChronoUnit.DAYS.between(a, e), cycle.toLong()), cycle, model.lead)
}
// backward-anchored
private fun shape(phase: Long, len: Int, lead: Int): Float {
if (phase < GREEN_PEAK) return 120f * (phase + 1) / (GREEN_PEAK + 1)
val du = (len - phase).toInt()
val top = len - GREEN_PEAK
val l = lead.coerceIn(1, maxOf(1, top - 1))
if (du > l) {
val f = ((du - l - 1).toFloat() / maxOf(1, top - l - 1)).coerceIn(0f, 1f)
return PMS_EDGE_HI + (120f - PMS_EDGE_HI) * f
}
val r = l / 2
return if (du <= r) 0f else PMS_EDGE_LO * (du - r) / (l - r)
}
private fun overdueHold(
starts: List<LocalDate>,
cycle: Int,
today: LocalDate,
last: LocalDate?,
date: LocalDate
): Long {
val anchor = last ?: return 0L
val pin = anchor.plusDays(cycle - 1L)
val end = starts.firstOrNull { it.isAfter(anchor) } ?: today
val lag = ChronoUnit.DAYS.between(pin, end)
if (lag <= 0L) return 0L
return ChronoUnit.DAYS.between(pin, date).coerceIn(0L, lag)
}
fun bucketToday(records: List<KilnDayRecord>, buttons: KilnButtonSet, today: LocalDate): Int {
val starts = KilnCyclePredictor.periodStarts(records, buttons)
val cycle = KilnCyclePredictor.avgCycleLength(starts)
return bucket(lookup(records, buttons, starts, cycle, today)(today))
}
}