diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/render/ascii.go | 20 | ||||
| -rw-r--r-- | internal/render/svg.go | 381 | ||||
| -rw-r--r-- | internal/render/svg_test.go | 215 |
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) + } + } +} |
