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 const gutter = 5 // "NN°" label plus the axis rule 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"), }) } } out := []string{""} for r := 0; r < height; r++ { full := (height - r) * 2 value := lo + (hi-lo)*float64(height-1-r)/float64(height-1) label := " " switch { case r == 0: label = x.c(TempStyle(x.celsius(hi)), PadLeft(fmt.Sprintf("%d°", Deg(hi)), 4)) case r == height-1: label = x.c(TempStyle(x.celsius(lo)), PadLeft(fmt.Sprintf("%d°", Deg(lo)), 4)) case height >= 5 && r == height/2: label = x.c(TempStyle(x.celsius(value)), PadLeft(fmt.Sprintf("%d°", Deg(value)), 4)) } 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 := fmt.Sprintf("%d-%d°", Deg(lo), Deg(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"), 4)+"│")+ 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{}