summaryrefslogtreecommitdiff
path: root/internal/render/table.go
blob: 3ade2964dbd3927f12dfe9b0a347fd562b15233a (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
239
240
package render

import (
	"fmt"
	"strings"

	"github.com/lukaszkasprzak/prognosis/internal/openmeteo"
)

// cell is one rendered table cell: text, its colour, and how it is aligned.
type cell struct {
	text  string
	style string
	left  bool
}

// colWidth is the reserved display width per column.
func (x ctx) colWidth(name string) int {
	switch name {
	case "hour":
		// Three, not two: the Python leaves a double space after the hour.
		return 3
	case "icon":
		return IconWidth(x.cfg.Icons)
	case "temp":
		return 5
	case "feels":
		return 6
	case "conditions":
		return 16
	case "mm", "rain", "wind", "gusts", "humidity", "dew", "uv", "cloud", "visibility":
		return 5
	case "dir":
		return 3
	case "pressure":
		return 6
	}
	return 6
}

func (x ctx) leftAligned(name string) bool {
	switch name {
	case "hour", "icon", "temp", "feels", "conditions":
		return true
	}
	return false
}

// visible drops columns that have nothing to say: the icon column when icons
// are off, and the rain pair on a dry window.
func (x ctx) visible() []string {
	var out []string
	for _, name := range x.cfg.Columns {
		if name == "icon" && x.cfg.Icons == "none" {
			continue
		}
		if (name == "mm" || name == "rain") && !x.wet {
			continue
		}
		out = append(out, name)
	}
	return out
}

// row assembles one line from cells, padding each to its column width BEFORE
// colouring it. Escape sequences carry no display width, so padding a coloured
// string misaligns every column to its right -- invisible when piped, obvious
// in a terminal.
func (x ctx) row(cells map[string]cell) string {
	var b strings.Builder
	for _, name := range x.visible() {
		c := cells[name]
		w := x.colWidth(name)
		padded := PadLeft(c.text, w)
		if x.leftAligned(name) {
			padded = Pad(c.text, w)
		}
		b.WriteString(" ")
		b.WriteString(x.c(c.style, padded))
	}
	return strings.TrimRight(b.String(), " ")
}

func (x ctx) table(v View) []string {
	out := []string{""}

	headers := map[string]cell{}
	for _, name := range x.visible() {
		headers[name] = cell{text: x.cat.Header(name), style: ""}
	}
	out = append(out, x.c(Underline, x.rowPlain(headers)))

	day := v.Rows[0].When.Format("2006-01-02")
	prevCode := -1
	for i, r := range v.Rows {
		if d := r.When.Format("2006-01-02"); d != day {
			day = d
			sep := " -- " + x.cat.Date(r.When) + " --"
			if sun, ok := v.Sun[d]; ok {
				sep += fmt.Sprintf("  %s %s / %s", x.cat.Word("sun"), sun[0], sun[1])
			}
			out = append(out, x.c(Dim, sep))
			prevCode = -1 // repeat the conditions once per day for context
		}
		out = append(out, x.row(x.cells(r, i == 0, &prevCode)))
	}
	return out
}

// rowPlain is the header row: padded like the data but never coloured per cell,
// so the underline runs unbroken across it.
func (x ctx) rowPlain(cells map[string]cell) string {
	var b strings.Builder
	for _, name := range x.visible() {
		w := x.colWidth(name)
		text := cells[name].text
		padded := PadLeft(text, w)
		if x.leftAligned(name) {
			padded = Pad(text, w)
		}
		b.WriteString(" ")
		b.WriteString(padded)
	}
	return b.String()
}

func (x ctx) cells(r openmeteo.Row, isNow bool, prevCode *int) map[string]cell {
	out := map[string]cell{}
	temp, hasTemp := r.Val("temperature_2m")

	for _, name := range x.visible() {
		switch name {
		case "hour":
			style := Reset
			if isNow {
				style = Bold
			}
			out[name] = cell{text: r.When.Format("15"), style: style}

		case "icon":
			out[name] = cell{text: Icon(x.cfg.Icons, r.Code)}

		case "temp":
			style := TempStyle(x.celsius(temp))
			// The current hour keeps its emphasis on top of the heat colour.
			if isNow {
				style = Bold + ";" + style
			}
			out[name] = cell{text: fmt.Sprintf("%d°", Deg(temp)), style: style}

		case "feels":
			text := ""
			if feels, ok := r.Val("apparent_temperature"); ok && hasTemp {
				// Only shown when it differs; otherwise it is a column of noise.
				if abs(feels-temp) >= 1 {
					text = fmt.Sprintf("(%d)", Deg(feels))
				}
			}
			out[name] = cell{text: text, style: Dim}

		case "conditions":
			text := ""
			// Only when they change: an unbroken column of "overcast" hides the
			// hour it stops being overcast, which is the only interesting part.
			if r.Code != *prevCode {
				text = Truncate(x.cat.Condition(r.Code), x.colWidth(name))
			}
			out[name] = cell{text: text}

		case "mm":
			mm, _ := r.Val("precipitation")
			style := Dim
			if mm > 0 {
				style = Cyan
			}
			out[name] = cell{text: round1(mm), style: style}

		case "rain":
			p, _ := r.Val("precipitation_probability")
			style := Dim
			if p >= 50 {
				style = Yellow
			}
			out[name] = cell{text: fmt.Sprintf("%d%%", int(p)), style: style}

		case "wind":
			v, _ := r.Val("wind_speed_10m")
			out[name] = cell{text: fmt.Sprintf("%d", int(v))}

		case "gusts":
			v, _ := r.Val("wind_gusts_10m")
			style := ""
			if v >= 60 {
				style = Yellow
			}
			out[name] = cell{text: fmt.Sprintf("%d", int(v)), style: style}

		case "dir":
			v, _ := r.Val("wind_direction_10m")
			out[name] = cell{text: compass(v)}

		case "humidity":
			v, _ := r.Val("relative_humidity_2m")
			out[name] = cell{text: fmt.Sprintf("%d%%", int(v)), style: Dim}

		case "dew":
			v, _ := r.Val("dew_point_2m")
			out[name] = cell{text: fmt.Sprintf("%d°", Deg(v)), style: Dim}

		case "uv":
			v, _ := r.Val("uv_index")
			style := Dim
			if v >= 6 {
				style = Yellow
			}
			out[name] = cell{text: round1(v), style: style}

		case "cloud":
			v, _ := r.Val("cloud_cover")
			out[name] = cell{text: fmt.Sprintf("%d%%", int(v)), style: Dim}

		case "pressure":
			v, _ := r.Val("pressure_msl")
			out[name] = cell{text: fmt.Sprintf("%d", int(v)), style: Dim}

		case "visibility":
			v, _ := r.Val("visibility")
			out[name] = cell{text: fmt.Sprintf("%.0fkm", v/1000), style: Dim}
		}
	}
	*prevCode = r.Code
	return out
}

func abs(f float64) float64 {
	if f < 0 {
		return -f
	}
	return f
}