aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/render/ascii.go20
-rw-r--r--internal/render/svg.go381
-rw-r--r--internal/render/svg_test.go215
3 files changed, 616 insertions, 0 deletions
diff --git a/internal/render/ascii.go b/internal/render/ascii.go
index 4203bc9..077a418 100644
--- a/internal/render/ascii.go
+++ b/internal/render/ascii.go
@@ -25,6 +25,26 @@ var asciiMap = map[rune]string{
'│': "|",
}
+// Transliterate maps non-ASCII characters to their ASCII equivalents where one
+// is defined, leaving ASCII alone. For contexts that must not carry diacritics
+// at all -- a generated filename, say -- where "Góra" should become "Gora"
+// rather than losing the letter.
+func Transliterate(s string) string {
+ var b strings.Builder
+ b.Grow(len(s))
+ for _, r := range s {
+ switch {
+ case r < 128:
+ b.WriteRune(r)
+ default:
+ if sub, ok := asciiMap[r]; ok {
+ b.WriteString(sub)
+ }
+ }
+ }
+ return b.String()
+}
+
// ASCII rewrites output to pure ASCII, one character for one character.
//
// The point is SMS: a single non-ASCII character forces the whole message from
diff --git a/internal/render/svg.go b/internal/render/svg.go
new file mode 100644
index 0000000..dcb9af5
--- /dev/null
+++ b/internal/render/svg.go
@@ -0,0 +1,381 @@
+package render
+
+import (
+ "encoding/xml"
+ "fmt"
+ "math"
+ "strings"
+ "time"
+
+ "github.com/lukaszkasprzak/prognosis/internal/config"
+ "github.com/lukaszkasprzak/prognosis/internal/i18n"
+ "github.com/lukaszkasprzak/prognosis/internal/openmeteo"
+)
+
+// SVGFields are the hourly fields a meteogram needs, whatever columns the table
+// happens to show. Requested in addition to the table's own fields.
+var SVGFields = []string{
+ "temperature_2m", "apparent_temperature", "precipitation",
+ "precipitation_probability", "relative_humidity_2m", "wind_speed_10m",
+}
+
+// Layout constants, in user units (which are CSS pixels at 1:1).
+const (
+ svgMarginLeft = 52
+ svgMarginRight = 44
+ svgMarginTop = 64
+ svgMarginBottom = 34
+ svgPanelGap = 24
+ svgHourWidth = 26 // per hour, before clamping
+ svgMinWidth = 640
+ svgMaxWidth = 1800
+)
+
+// panel is one stacked chart sharing the figure's time axis.
+type panel struct {
+ title string
+ height int
+ draw func(b *strings.Builder, p panelBox)
+}
+
+// panelBox is a panel's rectangle on the canvas.
+type panelBox struct {
+ x, y, w, h int
+}
+
+// SVG renders the forecast as a standalone meteogram.
+//
+// Written directly rather than through a plotting library or gnuplot: SVG is
+// markup, so this keeps prognosis dependency-free and works identically on a
+// machine that has no plotting tools at all -- the phone, for instance.
+func SVG(v View, cfg config.Config) string {
+ rows := v.Rows
+ if len(rows) == 0 {
+ return ""
+ }
+ cat := i18n.For(cfg.DisplayLang)
+
+ width := len(rows) * svgHourWidth
+ if width < svgMinWidth {
+ width = svgMinWidth
+ }
+ if width > svgMaxWidth {
+ width = svgMaxWidth
+ }
+ plotW := width - svgMarginLeft - svgMarginRight
+
+ series := func(field string) []float64 {
+ out := make([]float64, len(rows))
+ for i, r := range rows {
+ out[i], _ = r.Val(field)
+ }
+ return out
+ }
+ temp := series("temperature_2m")
+ feels := series("apparent_temperature")
+ rain := series("precipitation")
+ prob := series("precipitation_probability")
+ hum := series("relative_humidity_2m")
+
+ panels := []panel{
+ {title: cat.Header("temp") + " °", height: 170, draw: func(b *strings.Builder, p panelBox) {
+ plo, phi := paddedRange(append(append([]float64{}, temp...), feels...))
+ lo, hi, lines := niceTicks(plo, phi, 6)
+ svgGrid(b, p, lo, hi, lines, "%.0f")
+ svgLine(b, p, feels, lo, hi, "#c98", 1.5, true)
+ svgLine(b, p, temp, lo, hi, "#c33", 2.2, false)
+ svgLegend(b, p, cat.Header("temp"), cat.Header("feels"))
+ }},
+ {title: cat.Header("mm") + " (" + cat.Header("rain") + " %)", height: 104, draw: func(b *strings.Builder, p panelBox) {
+ hiRain := maxOf(rain)
+ if hiRain < 1 {
+ hiRain = 1 // an empty panel still needs a sane scale
+ }
+ svgGrid(b, p, 0, hiRain, 3, "%.1f")
+ // Two units share this panel, so the probability gets its own axis
+ // on the right. Without it the dashed line reads against millimetres.
+ svgRightAxis(b, p, 0, 100, 3, "%.0f%%", "#69b")
+ svgBars(b, p, rain, 0, hiRain, "#39c")
+ svgLine(b, p, prob, 0, 100, "#69b", 1.2, true)
+ }},
+ {title: cat.Header("humidity") + " %", height: 104, draw: func(b *strings.Builder, p panelBox) {
+ svgGrid(b, p, 0, 100, 3, "%.0f")
+ svgLine(b, p, hum, 0, 100, "#4a7", 1.8, false)
+ }},
+ }
+
+ height := svgMarginTop + svgMarginBottom
+ for i, p := range panels {
+ height += p.height
+ if i > 0 {
+ height += svgPanelGap
+ }
+ }
+
+ var b strings.Builder
+ fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" `+
+ `viewBox="0 0 %d %d" font-family="DejaVu Sans, Helvetica, sans-serif" font-size="11">`+"\n",
+ width, height, width, height)
+ fmt.Fprintf(&b, `<rect width="%d" height="%d" fill="#ffffff"/>`+"\n", width, height)
+
+ title := fmt.Sprintf("%s %s", v.Label, cat.Date(rows[0].When))
+ fmt.Fprintf(&b, `<text x="%d" y="26" font-size="15" fill="#222">%s</text>`+"\n",
+ svgMarginLeft, escape(title))
+ if sun, ok := v.Sun[rows[0].When.Format("2006-01-02")]; ok {
+ fmt.Fprintf(&b, `<text x="%d" y="43" fill="#777">%s %s %s %s</text>`+"\n",
+ svgMarginLeft, escape(sun[0]), escape(cat.Word("up")),
+ escape(sun[1]), escape(cat.Word("down")))
+ }
+
+ y := svgMarginTop
+ for i, p := range panels {
+ box := panelBox{x: svgMarginLeft, y: y, w: plotW, h: p.height}
+ svgNightBands(&b, box, rows, v.Sun)
+ svgTimeGrid(&b, box, rows)
+ p.draw(&b, box)
+ fmt.Fprintf(&b, `<rect x="%d" y="%d" width="%d" height="%d" fill="none" stroke="#bbb"/>`+"\n",
+ box.x, box.y, box.w, box.h)
+ fmt.Fprintf(&b, `<text x="%d" y="%d" fill="#555">%s</text>`+"\n",
+ box.x+4, box.y-4, escape(p.title))
+ if i == len(panels)-1 {
+ svgTimeAxis(&b, box, rows)
+ }
+ y += p.height + svgPanelGap
+ }
+
+ b.WriteString("</svg>\n")
+ return b.String()
+}
+
+// svgNightBands shades the hours between sunset and sunrise, which is what makes
+// a meteogram readable at a glance.
+func svgNightBands(b *strings.Builder, p panelBox, rows []openmeteo.Row, sun map[string][2]string) {
+ start := -1
+ for i, r := range rows {
+ night := isNight(r.When, sun)
+ if night && start < 0 {
+ start = i
+ }
+ if (!night || i == len(rows)-1) && start >= 0 {
+ end := i
+ if night {
+ end = i + 1
+ }
+ x0 := xAt(p, start, len(rows))
+ x1 := xAt(p, end, len(rows))
+ fmt.Fprintf(b, `<rect x="%.1f" y="%d" width="%.1f" height="%d" fill="#eef1f6"/>`+"\n",
+ x0, p.y, math.Max(x1-x0, 1), p.h)
+ start = -1
+ }
+ }
+}
+
+// isNight reports whether an hour falls outside that date's sunrise..sunset.
+// Without sun data it reports false: no shading beats wrong shading.
+func isNight(t time.Time, sun map[string][2]string) bool {
+ s, ok := sun[t.Format("2006-01-02")]
+ if !ok {
+ return false
+ }
+ rise, err1 := time.Parse("15:04", s[0])
+ set, err2 := time.Parse("15:04", s[1])
+ if err1 != nil || err2 != nil {
+ return false
+ }
+ mins := t.Hour()*60 + t.Minute()
+ return mins < rise.Hour()*60+rise.Minute() || mins >= set.Hour()*60+set.Minute()
+}
+
+// svgTimeGrid draws a faint line per labelled hour and a stronger one at each
+// midnight, so a value can be traced back to a time without counting squares.
+func svgTimeGrid(b *strings.Builder, p panelBox, rows []openmeteo.Row) {
+ every := hourStep(len(rows))
+ for i, r := range rows {
+ midnight := r.When.Hour() == 0
+ if !midnight && i%every != 0 {
+ continue
+ }
+ x := xAt(p, i, len(rows)-1)
+ stroke, w := "#eeeeee", 1.0
+ if midnight {
+ stroke, w = "#9aa3ad", 1.4
+ }
+ fmt.Fprintf(b, `<line x1="%.1f" y1="%d" x2="%.1f" y2="%d" stroke="%s" stroke-width="%.1f"/>`+"\n",
+ x, p.y, x, p.y+p.h, stroke, w)
+ }
+}
+
+// hourStep is how often the time axis is labelled, kept in one place so the
+// grid and the labels cannot disagree.
+func hourStep(n int) int { return 1 + n/16 }
+
+func svgGrid(b *strings.Builder, p panelBox, lo, hi float64, lines int, format string) {
+ for i := 0; i < lines; i++ {
+ frac := float64(i) / float64(lines-1)
+ y := float64(p.y+p.h) - frac*float64(p.h)
+ value := lo + (hi-lo)*frac
+ fmt.Fprintf(b, `<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" stroke="#e4e4e4"/>`+"\n",
+ p.x, y, p.x+p.w, y)
+ fmt.Fprintf(b, `<text x="%d" y="%.1f" text-anchor="end" fill="#888">%s</text>`+"\n",
+ p.x-6, y+3.5, escape(fmt.Sprintf(format, value)))
+ }
+}
+
+func svgLine(b *strings.Builder, p panelBox, vals []float64, lo, hi float64, colour string, w float64, dashed bool) {
+ if len(vals) == 0 {
+ return
+ }
+ var pts []string
+ for i, v := range vals {
+ pts = append(pts, fmt.Sprintf("%.1f,%.1f", xAt(p, i, len(vals)-1), yAt(p, v, lo, hi)))
+ }
+ dash := ""
+ if dashed {
+ dash = ` stroke-dasharray="4 3"`
+ }
+ fmt.Fprintf(b, `<polyline points="%s" fill="none" stroke="%s" stroke-width="%.1f" `+
+ `stroke-linejoin="round"%s/>`+"\n", strings.Join(pts, " "), colour, w, dash)
+}
+
+func svgBars(b *strings.Builder, p panelBox, vals []float64, lo, hi float64, colour string) {
+ if len(vals) < 2 {
+ return
+ }
+ bw := float64(p.w) / float64(len(vals)) * 0.7
+ for i, v := range vals {
+ if v <= 0 {
+ continue
+ }
+ y := yAt(p, v, lo, hi)
+ x := xAt(p, i, len(vals)-1) - bw/2
+ fmt.Fprintf(b, `<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="%s" opacity="0.75"/>`+"\n",
+ x, y, bw, float64(p.y+p.h)-y, colour)
+ }
+}
+
+// svgTimeAxis labels the hours, with dates in bold at midnight.
+//
+// A date label is wider than an hour, so any hour that would land under one is
+// dropped: otherwise "23" and "28.08" render on top of each other as "2328.08".
+func svgTimeAxis(b *strings.Builder, p panelBox, rows []openmeteo.Row) {
+ const collision = 22.0 // user units either side of a date label
+
+ var dateX []float64
+ for i, r := range rows {
+ if r.When.Hour() == 0 {
+ dateX = append(dateX, xAt(p, i, len(rows)-1))
+ }
+ }
+ near := func(x float64) bool {
+ for _, dx := range dateX {
+ if math.Abs(x-dx) < collision {
+ return true
+ }
+ }
+ return false
+ }
+
+ every := hourStep(len(rows))
+ for i, r := range rows {
+ x := xAt(p, i, len(rows)-1)
+ switch {
+ case r.When.Hour() == 0:
+ fmt.Fprintf(b, `<text x="%.1f" y="%d" text-anchor="middle" font-weight="bold" fill="#222">%s</text>`+"\n",
+ x, p.y+p.h+16, escape(r.When.Format("02.01")))
+ case i%every == 0 && !near(x):
+ fmt.Fprintf(b, `<text x="%.1f" y="%d" text-anchor="middle" fill="#666">%s</text>`+"\n",
+ x, p.y+p.h+16, escape(r.When.Format("15")))
+ }
+ }
+}
+
+// svgLegend names the two temperature series, since a dashed line is not
+// self-explanatory.
+func svgLegend(b *strings.Builder, p panelBox, solid, dashed string) {
+ x := float64(p.x+p.w) - 150
+ y := float64(p.y) + 14
+ fmt.Fprintf(b, `<rect x="%.1f" y="%.1f" width="146" height="18" fill="#ffffff" opacity="0.82"/>`+"\n",
+ x-6, y-12)
+ fmt.Fprintf(b, `<line x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f" stroke="#c33" stroke-width="2.2"/>`+"\n",
+ x, y-4, x+18, y-4)
+ fmt.Fprintf(b, `<text x="%.1f" y="%.1f" fill="#555">%s</text>`+"\n", x+23, y, escape(solid))
+ x2 := x + 23 + float64(len(solid))*6 + 12
+ fmt.Fprintf(b, `<line x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f" stroke="#c98" stroke-width="1.5" stroke-dasharray="4 3"/>`+"\n",
+ x2, y-4, x2+18, y-4)
+ fmt.Fprintf(b, `<text x="%.1f" y="%.1f" fill="#555">%s</text>`+"\n", x2+23, y, escape(dashed))
+}
+
+func xAt(p panelBox, i, n int) float64 {
+ if n <= 0 {
+ return float64(p.x)
+ }
+ return float64(p.x) + float64(i)/float64(n)*float64(p.w)
+}
+
+func yAt(p panelBox, v, lo, hi float64) float64 {
+ if hi-lo < 1e-9 {
+ return float64(p.y + p.h/2)
+ }
+ frac := (v - lo) / (hi - lo)
+ frac = math.Max(0, math.Min(1, frac))
+ return float64(p.y+p.h) - frac*float64(p.h)
+}
+
+// svgRightAxis labels the right-hand edge, for a panel carrying a second unit.
+func svgRightAxis(b *strings.Builder, p panelBox, lo, hi float64, lines int, format, colour string) {
+ for i := 0; i < lines; i++ {
+ frac := float64(i) / float64(lines-1)
+ y := float64(p.y+p.h) - frac*float64(p.h)
+ fmt.Fprintf(b, `<text x="%d" y="%.1f" fill="%s">%s</text>`+"\n",
+ p.x+p.w+6, y+3.5, colour, escape(fmt.Sprintf(format, lo+(hi-lo)*frac)))
+ }
+}
+
+// niceTicks snaps a range to round numbers and returns how many gridlines that
+// implies, so every label lands on a multiple of the step. Snapping the ends
+// alone is not enough: with a fixed number of lines the values between them
+// still come out as 12.5 and 27.5.
+func niceTicks(lo, hi float64, maxLines int) (float64, float64, int) {
+ span := hi - lo
+ if span <= 0 {
+ return lo, lo + 1, 2
+ }
+ step := math.Pow(10, math.Floor(math.Log10(span/float64(maxLines-1))))
+ for _, m := range []float64{1, 2, 2.5, 5, 10} {
+ if span/(step*m) <= float64(maxLines-1) {
+ step *= m
+ break
+ }
+ }
+ lo = math.Floor(lo/step) * step
+ hi = math.Ceil(hi/step) * step
+ return lo, hi, int(math.Round((hi-lo)/step)) + 1
+}
+
+// paddedRange leaves a margin above and below so a curve never touches the frame.
+func paddedRange(vals []float64) (float64, float64) {
+ if len(vals) == 0 {
+ return 0, 1
+ }
+ lo, hi := vals[0], vals[0]
+ for _, v := range vals {
+ lo, hi = math.Min(lo, v), math.Max(hi, v)
+ }
+ pad := math.Max((hi-lo)*0.15, 0.5)
+ return lo - pad, hi + pad
+}
+
+func maxOf(vals []float64) float64 {
+ m := 0.0
+ for _, v := range vals {
+ m = math.Max(m, v)
+ }
+ return m
+}
+
+// escape makes text safe for XML. A place name is user input and can contain &.
+func escape(s string) string {
+ var b strings.Builder
+ xml.EscapeText(&b, []byte(s))
+ return b.String()
+}
diff --git a/internal/render/svg_test.go b/internal/render/svg_test.go
new file mode 100644
index 0000000..075cb72
--- /dev/null
+++ b/internal/render/svg_test.go
@@ -0,0 +1,215 @@
+package render
+
+import (
+ "encoding/xml"
+ "math"
+ "regexp"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/lukaszkasprzak/prognosis/internal/openmeteo"
+)
+
+// svgView builds hours starting at 12:00, so a 24-hour view crosses midnight.
+func svgView(hours int) View {
+ v := View{Label: "Krakow, PL", Sun: map[string][2]string{}}
+ start := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC)
+ for i := 0; i < hours; i++ {
+ when := start.Add(time.Duration(i) * time.Hour)
+ v.Rows = append(v.Rows, openmeteo.Row{
+ When: when,
+ Code: 3,
+ Vals: map[string]float64{
+ "temperature_2m": 15 + 6*math.Sin(float64(i)/4),
+ "apparent_temperature": 14 + 6*math.Sin(float64(i)/4),
+ "precipitation": float64(i%7) * 0.1,
+ "precipitation_probability": float64(i % 100),
+ "relative_humidity_2m": 50 + float64(i%40),
+ },
+ })
+ v.Sun[when.Format("2006-01-02")] = [2]string{"05:47", "19:35"}
+ }
+ return v
+}
+
+func svgOf(t *testing.T, hours int) string {
+ t.Helper()
+ out := SVG(svgView(hours), testConfig())
+ if out == "" {
+ t.Fatal("SVG produced nothing")
+ }
+ return out
+}
+
+func TestSVGIsWellFormedXML(t *testing.T) {
+ out := svgOf(t, 24)
+ if err := xml.Unmarshal([]byte(out), new(any)); err != nil {
+ t.Fatalf("not well-formed XML: %v", err)
+ }
+ if !strings.HasPrefix(out, "<svg xmlns=") {
+ t.Error("missing the SVG namespace, so viewers will refuse it")
+ }
+}
+
+// A place name is user input and can carry characters XML reserves.
+func TestSVGEscapesTheLabel(t *testing.T) {
+ v := svgView(6)
+ v.Label = `Foo & <Bar>`
+ out := SVG(v, testConfig())
+ if strings.Contains(out, "<Bar>") || strings.Contains(out, "& ") {
+ t.Errorf("label was not escaped:\n%s", out)
+ }
+ if err := xml.Unmarshal([]byte(out), new(any)); err != nil {
+ t.Fatalf("unescaped label broke the XML: %v", err)
+ }
+}
+
+func TestSVGEmptyViewProducesNothing(t *testing.T) {
+ if got := SVG(View{}, testConfig()); got != "" {
+ t.Fatalf("expected empty output for no rows, got %d bytes", len(got))
+ }
+}
+
+// One point per hour, on every series.
+func TestSVGSeriesHaveAPointPerHour(t *testing.T) {
+ const hours = 18
+ out := svgOf(t, hours)
+ polylines := regexp.MustCompile(`<polyline points="([^"]+)"`).FindAllStringSubmatch(out, -1)
+ if len(polylines) < 4 {
+ t.Fatalf("expected at least four series, found %d", len(polylines))
+ }
+ for i, m := range polylines {
+ if n := len(strings.Fields(m[1])); n != hours {
+ t.Errorf("series %d has %d points, want %d", i, n, hours)
+ }
+ }
+}
+
+// Night shading is what makes a meteogram readable; without sun data there must
+// be none rather than a guess.
+func TestSVGNightBandsFollowSunData(t *testing.T) {
+ withSun := svgOf(t, 24)
+ if !strings.Contains(withSun, `fill="#eef1f6"`) {
+ t.Error("no night bands drawn despite sunrise/sunset being known")
+ }
+ v := svgView(24)
+ v.Sun = map[string][2]string{}
+ if strings.Contains(SVG(v, testConfig()), `fill="#eef1f6"`) {
+ t.Error("night bands drawn without sun data; that is a guess")
+ }
+}
+
+func TestIsNight(t *testing.T) {
+ sun := map[string][2]string{"2026-08-27": {"05:47", "19:35"}}
+ at := func(h int) time.Time { return time.Date(2026, 8, 27, h, 0, 0, 0, time.UTC) }
+ for _, c := range []struct {
+ hour int
+ want bool
+ }{{0, true}, {5, true}, {6, false}, {12, false}, {19, false}, {20, true}, {23, true}} {
+ if got := isNight(at(c.hour), sun); got != c.want {
+ t.Errorf("%02d:00 night=%v, want %v", c.hour, got, c.want)
+ }
+ }
+ // An unknown date must not be shaded on a guess.
+ if isNight(time.Date(2030, 1, 1, 3, 0, 0, 0, time.UTC), sun) {
+ t.Error("shaded a date with no sun data")
+ }
+}
+
+// The rain panel carries millimetres and percent. Without the right-hand axis
+// the dashed probability line is read against the millimetre scale.
+func TestSVGRainPanelHasBothAxes(t *testing.T) {
+ out := svgOf(t, 24)
+ if !strings.Contains(out, "100%") || !strings.Contains(out, "50%") {
+ t.Errorf("no percentage axis for the probability line:\n%s", out)
+ }
+}
+
+func TestSVGDatesAreBoldAndHoursDoNotCollide(t *testing.T) {
+ out := svgOf(t, 30) // crosses midnight
+ if !strings.Contains(out, `font-weight="bold"`) {
+ t.Error("date labels are not bold")
+ }
+
+ // Collect the x of every axis label, and check none sits on top of a date.
+ type label struct {
+ x float64
+ bold bool
+ }
+ var labels []label
+ re := regexp.MustCompile(`<text x="([0-9.]+)" y="\d+" text-anchor="middle"( font-weight="bold")?`)
+ for _, m := range re.FindAllStringSubmatch(out, -1) {
+ x, _ := strconv.ParseFloat(m[1], 64)
+ labels = append(labels, label{x: x, bold: m[2] != ""})
+ }
+ if len(labels) < 4 {
+ t.Fatalf("expected several axis labels, found %d", len(labels))
+ }
+ for _, a := range labels {
+ if !a.bold {
+ continue
+ }
+ for _, b := range labels {
+ if !b.bold && math.Abs(a.x-b.x) < 20 {
+ t.Errorf("an hour label at x=%.0f collides with the date at x=%.0f", b.x, a.x)
+ }
+ }
+ }
+}
+
+func TestSVGHasALegendForTheTwoTemperatureSeries(t *testing.T) {
+ out := svgOf(t, 12)
+ if strings.Count(out, ">temp<") == 0 || strings.Count(out, ">feels<") == 0 {
+ t.Errorf("the dashed series is unexplained without a legend:\n%s", out)
+ }
+}
+
+func TestSVGDrawsDayBoundaries(t *testing.T) {
+ crossing := svgOf(t, 30)
+ if !strings.Contains(crossing, `stroke="#9aa3ad"`) {
+ t.Error("no midnight rule drawn on a view that crosses midnight")
+ }
+ if strings.Contains(svgOf(t, 6), `stroke="#9aa3ad"`) {
+ t.Error("midnight rule drawn on a six-hour view that never reaches midnight")
+ }
+}
+
+// Axis labels must land on round numbers, and the count must follow the step.
+func TestNiceTicks(t *testing.T) {
+ for _, c := range []struct{ lo, hi float64 }{{9.4, 22.6}, {0, 1}, {-4.2, 3.1}, {990, 1030}} {
+ lo, hi, lines := niceTicks(c.lo, c.hi, 6)
+ if lo > c.lo || hi < c.hi {
+ t.Errorf("niceTicks(%v,%v) = %v,%v — must contain the range", c.lo, c.hi, lo, hi)
+ }
+ if lines < 2 || lines > 8 {
+ t.Errorf("niceTicks(%v,%v) wants %d lines", c.lo, c.hi, lines)
+ }
+ step := (hi - lo) / float64(lines-1)
+ for i := 0; i < lines; i++ {
+ v := lo + step*float64(i)
+ if math.Abs(v-math.Round(v*100)/100) > 1e-9 {
+ t.Errorf("tick %v is not a round value", v)
+ }
+ }
+ }
+}
+
+// The meteogram needs its own fields whatever the table shows.
+func TestSVGFieldsCoverEveryPanel(t *testing.T) {
+ for _, f := range []string{
+ "temperature_2m", "apparent_temperature", "precipitation",
+ "precipitation_probability", "relative_humidity_2m",
+ } {
+ found := false
+ for _, have := range SVGFields {
+ if have == f {
+ found = true
+ }
+ }
+ if !found {
+ t.Errorf("SVGFields is missing %q, so that panel would be empty", f)
+ }
+ }
+}