aboutsummaryrefslogtreecommitdiff
path: root/internal/render/render.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/render/render.go')
-rw-r--r--internal/render/render.go283
1 files changed, 283 insertions, 0 deletions
diff --git a/internal/render/render.go b/internal/render/render.go
new file mode 100644
index 0000000..4417763
--- /dev/null
+++ b/internal/render/render.go
@@ -0,0 +1,283 @@
+// Package render turns fetched weather into terminal output. It is pure: no
+// network, no clock beyond what it is given, so every rule below is testable.
+package render
+
+import (
+ "fmt"
+ "math"
+ "strings"
+
+ "github.com/lukaszkasprzak/prognosis/internal/config"
+ "github.com/lukaszkasprzak/prognosis/internal/i18n"
+ "github.com/lukaszkasprzak/prognosis/internal/imgw"
+ "github.com/lukaszkasprzak/prognosis/internal/openmeteo"
+)
+
+// RainInterestPct is the chance of rain below which the mm and rain columns are
+// hidden: on a dry day they are a block of zeroes that pushes real content
+// sideways.
+const RainInterestPct = 20
+
+// View is everything one run needs to render.
+type View struct {
+ Label string
+ TZ string
+ Rows []openmeteo.Row
+ Sun map[string][2]string
+ Daily map[string]float64
+ Pollen map[string]float64
+ Warnings []imgw.Warning
+ WarnNote string
+ WarnFailed bool
+}
+
+type ctx struct {
+ cfg config.Config
+ cat *i18n.Catalog
+ c func(string, string) string
+ width int
+ wet bool
+}
+
+// celsius converts a displayed temperature back to Celsius.
+//
+// IMGW's thresholds are defined in Celsius, so they must be compared in
+// Celsius: a warning threshold does not move because the display was switched
+// to Fahrenheit. Without this, 85F (a mild 29C) renders in the "IMGW would warn
+// about this" red.
+func (x ctx) celsius(v float64) float64 {
+ if x.cfg.Units == "imperial" {
+ return (v - 32) * 5 / 9
+ }
+ return v
+}
+
+// Render produces the whole output.
+func Render(v View, cfg config.Config, width int, colour bool) string {
+ cx := ctx{
+ cfg: cfg,
+ cat: i18n.For(cfg.DisplayLang),
+ c: Styler(colour),
+ width: width,
+ wet: isWet(v.Rows),
+ }
+ var out []string
+ out = append(out, cx.warnings(v)...)
+ out = append(out, cx.header(v)...)
+ out = append(out, cx.table(v)...)
+ if cfg.Graph {
+ out = append(out, cx.chart(v)...)
+ }
+ text := strings.Join(out, "\n")
+ // Applied last, one character for one, so the layout above is unaffected.
+ if cfg.ASCII {
+ text = ASCII(text, cfg.Units)
+ }
+ return text
+}
+
+func isWet(rows []openmeteo.Row) bool {
+ for _, r := range rows {
+ if mm, ok := r.Val("precipitation"); ok && mm > 0 {
+ return true
+ }
+ if p, ok := r.Val("precipitation_probability"); ok && p >= RainInterestPct {
+ return true
+ }
+ }
+ return false
+}
+
+// warnings renders IMGW warnings, or an explicit line saying why none are shown.
+//
+// WarnFailed must never look the same as "none in force": silence would be read
+// as all-clear.
+func (x ctx) warnings(v View) []string {
+ var out []string
+ switch {
+ case x.WarnDisabled():
+ return nil
+ case v.WarnFailed:
+ out = append(out, x.c(Dim, " "+x.cat.Word("warnings")+": "+x.cat.Word("could_not_check")))
+ case v.WarnNote != "":
+ out = append(out, x.c(Dim, " "+x.cat.Word("warnings")+": "+v.WarnNote))
+ }
+ for _, w := range v.Warnings {
+ style := Yellow
+ switch w.Level {
+ case "2":
+ style = Red
+ case "3":
+ style = Bold + ";" + Red
+ }
+ head := fmt.Sprintf(" ! %s %s %s %s -> %s (%s%%)",
+ w.Event, x.cat.Word("level"), w.Level,
+ clip(w.From), clip(w.To), w.Probability)
+ out = append(out, x.c(style, head))
+ for _, line := range wrap(w.Text, x.width) {
+ out = append(out, x.c(Dim, " "+line))
+ }
+ }
+ if len(out) > 0 {
+ out = append(out, "")
+ }
+ return out
+}
+
+// WarnDisabled reports whether warnings were switched off in config.
+func (x ctx) WarnDisabled() bool { return !x.cfg.Warnings }
+
+// clip shortens "2026-08-10 11:00:00" to "08-10 11:00".
+func clip(s string) string {
+ if len(s) >= 16 {
+ return s[5:16]
+ }
+ return s
+}
+
+func (x ctx) header(v View) []string {
+ var out []string
+ first := v.Rows[0].When
+ title := v.Label + " " + x.cat.Date(first)
+ if x.cfg.Minimal {
+ // Just the place and the date: no sun times, no summary, no pollen.
+ return append(out, x.c(Bold, title))
+ }
+ sun, hasSun := v.Sun[first.Format("2006-01-02")]
+ suffix := ""
+ if hasSun {
+ suffix = fmt.Sprintf(" %s %s %s %s",
+ sun[0], x.cat.Word("up"), sun[1], x.cat.Word("down"))
+ }
+ tz := ""
+ if v.TZ != "" {
+ tz = " " + v.TZ
+ }
+ // Keep it to one line where it fits; a phone is narrow enough that it often
+ // does not, and a wrapped title reads worse than two deliberate lines.
+ if hasSun && DisplayWidth(title)+DisplayWidth(suffix)+DisplayWidth(tz) <= x.width {
+ out = append(out, x.c(Bold, title)+x.c(Dim, suffix+tz))
+ } else {
+ out = append(out, x.c(Bold, title)+x.c(Dim, tz))
+ if hasSun {
+ out = append(out, x.c(Dim, fmt.Sprintf(" %s %s %s %s %s",
+ x.cat.Word("sun"), sun[0], x.cat.Word("up"), sun[1], x.cat.Word("down"))))
+ }
+ }
+
+ if len(v.Daily) > 0 {
+ var bits []string
+ lo, okLo := v.Daily["temperature_2m_min"]
+ hi, okHi := v.Daily["temperature_2m_max"]
+ if okLo && okHi {
+ bits = append(bits, fmt.Sprintf("%d-%d°", Deg(lo), Deg(hi)))
+ }
+ if mm, ok := v.Daily["precipitation_sum"]; ok {
+ if mm == 0 {
+ bits = append(bits, x.cat.Word("dry"))
+ } else {
+ bits = append(bits, fmt.Sprintf("%s %.1fmm %s %.0fh",
+ x.cat.Word("rainfall"), mm, x.cat.Word("over"),
+ v.Daily["precipitation_hours"]))
+ }
+ }
+ if sun, ok := v.Daily["sunshine_duration"]; ok {
+ if day, ok2 := v.Daily["daylight_duration"]; ok2 {
+ bits = append(bits, fmt.Sprintf("%s %s %s %s %s",
+ x.cat.Word("sun"), hm(sun), x.cat.Word("of"), hm(day),
+ x.cat.Word("daylight")))
+ }
+ }
+ label := " " + Pad(x.cat.Word("day"), 6) + " "
+ // Joined with wide separators when it fits; only a line too long for the
+ // terminal is re-wrapped, and then on single spaces.
+ joined := strings.Join(bits, " ")
+ lines := []string{joined}
+ if DisplayWidth(label)+DisplayWidth(joined) > x.width {
+ lines = wrap(joined, x.width-DisplayWidth(label))
+ }
+ for i, line := range lines {
+ prefix := label
+ if i > 0 {
+ prefix = strings.Repeat(" ", DisplayWidth(label))
+ }
+ out = append(out, x.c(Dim, prefix+line))
+ }
+ }
+
+ if len(v.Pollen) > 0 {
+ var bits []string
+ for _, s := range sortedByValue(v.Pollen) {
+ band := PollenBand(s, v.Pollen[s])
+ // Skip taxa that are simply absent, but never hide grass: it is the
+ // one someone may be allergic to and its absence is information.
+ if band == "none" && s != "grass" {
+ continue
+ }
+ text := fmt.Sprintf("%s %.1f", x.cat.Species(s), v.Pollen[s])
+ if band != "" {
+ text += " " + x.cat.Band(band)
+ }
+ bits = append(bits, text)
+ }
+ if len(bits) > 0 {
+ if len(bits) > 4 {
+ bits = bits[:4]
+ }
+ out = append(out, x.c(Dim, " "+Pad(x.cat.Word("pollen"), 6)+" ")+strings.Join(bits, " "))
+ }
+ }
+ return out
+}
+
+func hm(seconds float64) string {
+ s := int(seconds)
+ return fmt.Sprintf("%dh%02dm", s/3600, (s%3600)/60)
+}
+
+func sortedByValue(m map[string]float64) []string {
+ keys := make([]string, 0, len(m))
+ for k := range m {
+ keys = append(keys, k)
+ }
+ for i := 1; i < len(keys); i++ {
+ for j := i; j > 0 && m[keys[j]] > m[keys[j-1]]; j-- {
+ keys[j], keys[j-1] = keys[j-1], keys[j]
+ }
+ }
+ return keys
+}
+
+func wrap(text string, width int) []string {
+ if width < 8 {
+ width = 8
+ }
+ var lines []string
+ var line string
+ for _, word := range strings.Fields(text) {
+ switch {
+ case line == "":
+ line = word
+ case DisplayWidth(line)+1+DisplayWidth(word) <= width:
+ line += " " + word
+ default:
+ lines = append(lines, line)
+ line = word
+ }
+ }
+ if line != "" {
+ lines = append(lines, line)
+ }
+ return lines
+}
+
+func round1(v float64) string { return fmt.Sprintf("%.1f", v) }
+
+func compass(deg float64) string {
+ dirs := []string{"↓", "↙", "←", "↖", "↑", "↗", "→", "↘"}
+ i := int(math.Mod(math.Round(deg/45), 8))
+ if i < 0 {
+ i += 8
+ }
+ return dirs[i]
+}