All Downloads are FREE. Search and download functionalities are using the official Maven repository.

tri.covid19.coda.forecast.TimeSeriesInfoPanel.kt Maven / Gradle / Ivy

/*-
 * #%L
 * coda-app
 * --
 * Copyright (C) 2020 - 2022 Elisha Peterson
 * --
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * 
 *      http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 * #L%
 */
package tri.covid19.coda.forecast

import javafx.beans.property.SimpleObjectProperty
import javafx.beans.property.SimpleStringProperty
import javafx.geometry.Pos
import javafx.scene.paint.Color
import javafx.scene.paint.Paint
import javafx.scene.text.Text
import tornadofx.*
import tri.area.usa.UsaAreaLookup
import tri.covid19.coda.hotspot.HotspotInfo
import tri.timeseries.*
import tri.timeseries.analytics.ExtremaSummary
import tri.timeseries.analytics.ExtremeInfo
import tri.timeseries.analytics.ExtremeType
import tri.timeseries.analytics.MinMaxFinder
import tri.util.minus
import tri.util.monthDay
import tri.util.percentFormat
import tri.util.userFormat
import kotlin.math.absoluteValue

/** Panel that shows information about an underlying [TimeSeries]. */
class TimeSeriesInfoPanel(val series: SimpleObjectProperty) : View() {

    private val popText = SimpleStringProperty("")
    private val peakText = SimpleStringProperty("")
    private val doublingText = SimpleStringProperty("")
    private val recentChangeText = SimpleStringProperty("")
    private val currentTrendText = SimpleStringProperty("")
    private val currentTrendColor = SimpleObjectProperty(Color.BLACK)
    private val dataInfo = observableListOf()

    init {
        series.onChange { update(it) }
    }

    override val root = scrollpane {
        form {
            fieldset("Data Characteristics") {
                field("Population") { label(popText) }
                field("Peak") { label(peakText) }
                field("Doubling Time") { label(doublingText) }
                field("Recent Change") { label(recentChangeText) }
                field("Current Trend") {
                    label(currentTrendText) {
                        textFillProperty().bind(currentTrendColor)
                    }
                }
                field("History") {
                    labelContainer.alignment = Pos.TOP_LEFT
                    textflow { bindChildren(dataInfo) { it } }
                }
            }
        }
    }

    fun update(s: TimeSeries?) {
        if (s == null) {
            popText.value = ""
            peakText.value = ""
            doublingText.value = ""
            recentChangeText.value = ""
            currentTrendText.value = ""
            dataInfo.setAll()
            return
        }

        popText.value = UsaAreaLookup.area(s.areaId).population?.userFormat() ?: "unknown"

        val deltas = s.deltas()
        val smoothedDeltas = deltas.movingAverage(7)
        val peak = deltas.peak()
        val peakSmoothed = smoothedDeltas.peak()
        peakText.value = (if (peak == null) "(peak cannot be calculated), " else "${peak.second.userFormat()} on ${peak.first.monthDay}, ") +
                (if (peakSmoothed == null) "(smoothed peak cannot be calculated)" else "${peakSmoothed.second.userFormat()} on ${peakSmoothed.first.monthDay} (smoothed)")

        val hotspotInfo = HotspotInfo(s)
        doublingText.value = "${hotspotInfo.doublingTimeDays?.userFormat() ?: "N/A"} days (all time), ${hotspotInfo.doublingTimeDays28?.userFormat() ?: "N/A"} days (last 28 days)"
        recentChangeText.value = "${hotspotInfo.threeDayPercentChange?.percentFormat() ?: "N/A"} (3 day change), ${hotspotInfo.sevenDayPercentChange?.percentFormat() ?: "N/A"} (7 day change)"

        val extrema = s.deltas().extrema()
        currentTrendText.value = extrema.currentTrendText()
        currentTrendColor.value = when {
            "▲" in currentTrendText.value -> Color.DARKRED
            "▼" in currentTrendText.value -> Color.DARKGREEN
            else -> Color.BLACK
        }
        dataInfo.setAll(extrema.textInfo())
    }

    private fun TimeSeries.extrema() = MinMaxFinder(10).invoke(restrictNumberOfStartingZerosTo(1).movingAverage(7))

    //region EXTREMA TEXT

    /** Determine last extremum either at least 14 days from current, or where current value is at least 20% deviation, report on trend from that. */
    private fun ExtremaSummary.currentTrendText(): String {
        val curValue = extrema.values.last().value
        val curDate = extrema.keys.last()
        val anchorDate = extrema.keys.reversed().firstOrNull { curDate.minus(it) >= 14 ||
                curDate.minus(it) >= 7 && extrema[it]!!.value.percentChangeTo(curValue).absoluteValue >= .1 ||
                extrema[it]!!.value.percentChangeTo(curValue).absoluteValue >= .2 } ?: return ""
        val anchorValue = extrema[anchorDate]!!.value
        return when {
            curValue < anchorValue -> "▼ ${curDate.minus(anchorDate)} days     ${(curValue - anchorValue).userFormat()} since last peak (${anchorValue.percentChangeTo(curValue).percentFormat()})"
            curValue > anchorValue -> "▲ ${curDate.minus(anchorDate)} days     +${(curValue - anchorValue).userFormat()} since last valley (+${anchorValue.percentChangeTo(curValue).percentFormat()})"
            curValue == anchorValue -> "${curDate.minus(anchorDate)} days stable"
            else -> ""
        }
    }

    private fun ExtremaSummary.textInfo(): List {
        val extremaValues = extrema.values.toList()
        val globalMin = extremaValues.map { it.value }.minOrNull()!!
        val globalMax = extremaValues.map { it.value }.maxOrNull()!!
        return extremaValues.mapIndexed { i, cur -> cur.text(extremaValues.getOrNull(i - 1), globalMin, globalMax, i == extremaValues.size - 1) }
                .flatMap { it + Text(System.lineSeparator()) }
    }

    private fun ExtremeInfo.text(last: ExtremeInfo?, globalMin: Double, globalMax: Double, isLast: Boolean): List {
        if (last == null) {
            return listOf(Text("${date.monthDay}:".padStart(6) + "     first data point at ${value.userFormat()}"))
        } else if (value == last.value) {
            return listOf(Text("   ~ ${date.minus(last.date)} days at ${value.userFormat()}").apply { fill = Color.DARKGRAY })
        } else if (isLast && date.minus(last.date) <= 2L) {
            val text1 = "Currently at ${value.userFormat()}"
            val text2 = when {
                value == globalMin -> " (global min)"
                value == globalMax -> " (global max)"
                else -> ""
            }
            return listOf(Text(text1), Text(text2).apply { fill = Color.BLUE })
        }

        val increasing = value > last.value
        val text1 = "   ${if (increasing) "▲" else "▼"} ${date.minus(last.date)} days"
        val text2 = "${date.monthDay}:".padStart(6) + "\t${value.userFormat()}"
        val text3 = when (type) {
            ExtremeType.LOCAL_MIN, ExtremeType.GLOBAL_MIN -> minString(this, globalMin)
            ExtremeType.LOCAL_MAX, ExtremeType.GLOBAL_MAX -> maxString(this, globalMax)
            ExtremeType.ENDPOINT -> ""
        }
        val lastReferenceText = when (last.type) {
            ExtremeType.ENDPOINT -> "first data point"
            ExtremeType.LOCAL_MAX, ExtremeType.GLOBAL_MAX -> "last peak"
            ExtremeType.LOCAL_MIN, ExtremeType.GLOBAL_MIN -> "last valley"
        }
        val text4 = when {
            increasing -> "+${(value - last.value).userFormat()} since $lastReferenceText (+${last.value.percentChangeTo(value).percentFormat()})"
            else -> "${(value - last.value).userFormat()} since $lastReferenceText (${last.value.percentChangeTo(value).percentFormat()})"
        }

        return listOf(text1, System.lineSeparator(), "$text2 ", if (text3.isNotEmpty()) "($text3)" else "", "\t$text4")
                .map {
                    Text(it).apply {
                        when {
                            "global" in it -> fill = Color.BLUE
                            "▲" in it -> fill = Color.DARKRED
                            "▼" in it -> fill = Color.DARKGREEN
                        }
                    }
                }
    }

    private fun minString(v: ExtremeInfo, global: Double) = if (v.value == global) "global min" else if (v.type == ExtremeType.ENDPOINT) "" else "local min"
    private fun maxString(v: ExtremeInfo, global: Double) = if (v.value == global) "global max" else if (v.type == ExtremeType.ENDPOINT) "" else "local max"

    private fun Double.percentChangeTo(count: Double) = (count - this) / this

    //endregion

}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy