1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
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{}
|