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")
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()
}