aboutsummaryrefslogtreecommitdiff
path: root/internal/render/chart.go
blob: ce41364418805b063878ed8157b51a42d81d2dce (plain) (blame)
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
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
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
}