package render import ( "fmt" "math" "strings" "github.com/lukaszkasprzak/prognosis/internal/openmeteo" ) const blocks = "▁▂▃▄▅▆▇█" // column is one drawn chart column. Keeping level, colour, rain and hour in one // struct means they cannot drift apart, which four parallel slices would allow. type column struct { level int style string rain float64 hour string } // chart draws the temperature over several rows with a labelled axis. // // A one-row sparkline gives only 8 levels, so a single cold hour flattens the // rest of the week into the top two blocks. Drawing over height rows with // half-block cells gives height*2 levels, enough to see the daily rise and fall. // Long spans are downsampled so the chart fits the terminal: a week is 168 // hourly points and would otherwise wrap into mush. func (x ctx) chart(v View) []string { height := x.cfg.GraphHeight // Wide enough for the widest label the axis can produce -- "16.0°" when the // range is too narrow for whole degrees -- plus the axis rule. const gutter = 6 cols := x.width - gutter - 1 if cols < 8 { cols = 8 } groups := buckets(min(len(v.Rows), cols), len(v.Rows)) temps := make([]float64, len(groups)) rains := make([]float64, len(groups)) for i, g := range groups { sum, maxRain := 0.0, 0.0 for _, r := range v.Rows[g[0]:g[1]] { t, _ := r.Val("temperature_2m") sum += t if mm, ok := r.Val("precipitation"); ok && mm > maxRain { maxRain = mm } } temps[i] = sum / float64(g[1]-g[0]) rains[i] = maxRain } lo, hi := temps[0], temps[0] for _, t := range temps { lo, hi = math.Min(lo, t), math.Max(hi, t) } span := hi - lo if span == 0 { span = 1 } steps := height * 2 // A 12-hour chart would occupy 12 of 80 columns; widen each point to use the // terminal rather than leaving the curve cramped in the corner. scale := cols / len(groups) if scale < 1 { scale = 1 } var drawn []column for i, g := range groups { lvl := int(math.Round((temps[i] - lo) / span * float64(steps))) if lvl < 1 { lvl = 1 // always one half-cell, so the coldest column still shows } for k := 0; k < scale; k++ { drawn = append(drawn, column{ level: lvl, style: TempStyle(x.celsius(temps[i])), rain: rains[i], hour: v.Rows[g[0]].When.Format("15"), }) } } // Whole degrees cannot label a narrow range: a span of 1.2 degrees makes the // middle and bottom rows both read "15", which says the two rows are the // same temperature when they are not. Below the threshold, use a decimal. // Falls back to whole degrees if a decimal label would not fit the gutter, // which only happens well below freezing. axisLabel := tempLabeller(lo, hi, gutter-1) out := []string{""} for r := 0; r < height; r++ { full := (height - r) * 2 value := lo + (hi-lo)*float64(height-1-r)/float64(height-1) label := strings.Repeat(" ", gutter-1) switch { case r == 0: label = x.c(TempStyle(x.celsius(hi)), PadLeft(axisLabel(hi), gutter-1)) case r == height-1: label = x.c(TempStyle(x.celsius(lo)), PadLeft(axisLabel(lo), gutter-1)) case height >= 5 && r == height/2: label = x.c(TempStyle(x.celsius(value)), PadLeft(axisLabel(value), gutter-1)) } cells := make([]Cell, 0, len(drawn)) for _, d := range drawn { switch { case d.level >= full: cells = append(cells, Cell{Style: d.style, Text: "█"}) case d.level == full-1: cells = append(cells, Cell{Style: d.style, Text: "▄"}) default: cells = append(cells, Cell{Text: " "}) } } out = append(out, label+x.c(Dim, "│")+Paint(cells, x.c)) } note := axisLabel(lo) + "-" + axisLabel(hi) if len(groups) < len(v.Rows) { note += fmt.Sprintf(" %.0f%s", float64(len(v.Rows))/float64(len(groups)), x.cat.Word("h_per_col")) } // A flat row of empty blocks says nothing; only draw rain if there is any. maxRain := 0.0 for _, d := range drawn { maxRain = math.Max(maxRain, d.rain) } if maxRain > 0 { series := make([]float64, len(drawn)) for i, d := range drawn { series[i] = d.rain } out = append(out, x.c(Dim, PadLeft(x.cat.Word("rain_row"), gutter-1)+"│")+ x.c(Cyan, spark(series))+ x.c(Dim, fmt.Sprintf(" %s %.1fmm", x.cat.Word("max"), maxRain))) } every := scale * maxInt(1, ceilDiv(len(groups), 8)) // at most 8 labels hours := make([]string, len(drawn)) for i, d := range drawn { hours[i] = d.hour } out = append(out, x.c(Dim, strings.Repeat(" ", gutter)+axis(hours, every))) out = append(out, x.c(Dim, strings.Repeat(" ", gutter)+note)) return out } // axis places hour labels under the chart, one character per column so they // line up. A label that would run off the end is skipped rather than printed as // a half label. func axis(hours []string, every int) string { line := []rune(strings.Repeat(" ", len(hours))) for i := 0; i < len(hours); i += every { label := []rune(hours[i]) if i+len(label) > len(line) { continue } copy(line[i:], label) } return string(line) } // buckets splits total rows into count contiguous groups. func buckets(count, total int) [][2]int { out := make([][2]int, count) for i := range out { lo := i * total / count hi := (i + 1) * total / count if hi <= lo { hi = lo + 1 } out[i] = [2]int{lo, hi} } return out } // spark renders one block character per value, scaled to the series' own range. func spark(values []float64) string { lo, hi := values[0], values[0] for _, v := range values { lo, hi = math.Min(lo, v), math.Max(hi, v) } if hi-lo < 1e-9 { // flat: sit on the baseline rather than divide by zero return strings.Repeat(string([]rune(blocks)[0]), len(values)) } runes := []rune(blocks) step := (hi - lo) / float64(len(runes)-1) var b strings.Builder for _, v := range values { b.WriteRune(runes[int(math.Round((v-lo)/step))]) } return b.String() } func min(a, b int) int { if a < b { return a } return b } func maxInt(a, b int) int { if a > b { return a } return b } func ceilDiv(a, b int) int { return (a + b - 1) / b } var _ = openmeteo.Row{} // minSpanForWholeDegrees is the range below which whole-degree axis labels stop // distinguishing rows. With five rows a 2-degree span puts adjacent labels half // a degree apart, which still rounds to distinct values; below that they // collide. const minSpanForWholeDegrees = 2.0 // tempLabeller returns a formatter for the y-axis, choosing precision from the // range so that labels stay distinct, and never exceeding maxWidth cells. func tempLabeller(lo, hi float64, maxWidth int) func(float64) string { whole := func(v float64) string { return fmt.Sprintf("%d°", Deg(v)) } if hi-lo >= minSpanForWholeDegrees { return whole } decimal := func(v float64) string { return fmt.Sprintf("%.1f°", v) } // A decimal label is two characters longer; if that will not fit, whole // degrees are wrong but legible, which beats a sheared column. for _, v := range []float64{lo, hi} { if DisplayWidth(decimal(v)) > maxWidth { return whole } } return decimal }