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, ``+"\n", width, height, width, height) fmt.Fprintf(&b, ``+"\n", width, height) title := fmt.Sprintf("%s %s", v.Label, cat.Date(rows[0].When)) fmt.Fprintf(&b, `%s`+"\n", svgMarginLeft, escape(title)) if sun, ok := v.Sun[rows[0].When.Format("2006-01-02")]; ok { fmt.Fprintf(&b, `%s %s %s %s`+"\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, ``+"\n", box.x, box.y, box.w, box.h) fmt.Fprintf(&b, `%s`+"\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("\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, ``+"\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, ``+"\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, ``+"\n", p.x, y, p.x+p.w, y) fmt.Fprintf(b, `%s`+"\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, ``+"\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, ``+"\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, `%s`+"\n", x, p.y+p.h+16, escape(r.When.Format("02.01"))) case i%every == 0 && !near(x): fmt.Fprintf(b, `%s`+"\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, ``+"\n", x-6, y-12) fmt.Fprintf(b, ``+"\n", x, y-4, x+18, y-4) fmt.Fprintf(b, `%s`+"\n", x+23, y, escape(solid)) x2 := x + 23 + float64(len(solid))*6 + 12 fmt.Fprintf(b, ``+"\n", x2, y-4, x2+18, y-4) fmt.Fprintf(b, `%s`+"\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, `%s`+"\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() }