diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-13 13:04:10 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-13 13:04:10 +0200 |
| commit | e14a7db4ffe3c4e0f15f6b37a980501a8d74d26b (patch) | |
| tree | 670ef0897839871a64d3a3bb2e17e242e7d6c385 /internal/render/chart.go | |
| download | prognosis-e14a7db4ffe3c4e0f15f6b37a980501a8d74d26b.tar.gz prognosis-e14a7db4ffe3c4e0f15f6b37a980501a8d74d26b.zip | |
Initial commit: prognosis, the Go implementation
An hour-by-hour forecast for the terminal, with official IMGW warnings for
Polish locations. Replaces the Python version, whose cache file format it
keeps so the two can coexist until this reaches parity.
Open-Meteo provides the forecast, geocoding and pollen; GUGiK turns
coordinates into a TERYT powiat code; IMGW supplies the warnings, filtered
to that powiat rather than the whole country. Only the two lookups that
never change are cached. Forecasts never are.
Silence is never allowed to read as all-clear: "no warnings in force" and
"the check failed" are reported as distinct states.
Place names are resolved without guessing. A name matching several places
is refused with a numbered list carrying each candidate's region and
coordinates, and -pick N chooses one and remembers it. A stray positional
beside -l is an error, so an unquoted "Wiry, PL" cannot silently resolve to
somewhere else.
The cache is written one entry per line with sorted keys, and treated as
disposable but not worthless: an entry that will not parse is skipped and
the rest kept, and a file that will not parse at all is moved to
cache.json.bad rather than overwritten.
No third-party dependencies. `make ci` is the gate: gofmt clean, vet, tests.
Diffstat (limited to 'internal/render/chart.go')
| -rw-r--r-- | internal/render/chart.go | 205 |
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{} |
