summaryrefslogtreecommitdiff
path: root/internal/render/chart.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/render/chart.go')
-rw-r--r--internal/render/chart.go205
1 files changed, 205 insertions, 0 deletions
diff --git a/internal/render/chart.go b/internal/render/chart.go
new file mode 100644
index 0000000..cc20ca9
--- /dev/null
+++ b/internal/render/chart.go
@@ -0,0 +1,205 @@
+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{}