diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/render/chart.go | 47 | ||||
| -rw-r--r-- | internal/render/render_test.go | 83 |
2 files changed, 123 insertions, 7 deletions
diff --git a/internal/render/chart.go b/internal/render/chart.go index cc20ca9..ce41364 100644 --- a/internal/render/chart.go +++ b/internal/render/chart.go @@ -28,7 +28,9 @@ type column struct { // hourly points and would otherwise wrap into mush. func (x ctx) chart(v View) []string { height := x.cfg.GraphHeight - const gutter = 5 // "NN°" label plus the axis rule + // Wide enough for the widest label the axis can produce -- "16.0°" when the + // range is too narrow for whole degrees -- plus the axis rule. + const gutter = 6 cols := x.width - gutter - 1 if cols < 8 { cols = 8 @@ -82,18 +84,25 @@ func (x ctx) chart(v View) []string { } } + // Whole degrees cannot label a narrow range: a span of 1.2 degrees makes the + // middle and bottom rows both read "15", which says the two rows are the + // same temperature when they are not. Below the threshold, use a decimal. + // Falls back to whole degrees if a decimal label would not fit the gutter, + // which only happens well below freezing. + axisLabel := tempLabeller(lo, hi, gutter-1) + out := []string{""} for r := 0; r < height; r++ { full := (height - r) * 2 value := lo + (hi-lo)*float64(height-1-r)/float64(height-1) - label := " " + label := strings.Repeat(" ", gutter-1) switch { case r == 0: - label = x.c(TempStyle(x.celsius(hi)), PadLeft(fmt.Sprintf("%d°", Deg(hi)), 4)) + label = x.c(TempStyle(x.celsius(hi)), PadLeft(axisLabel(hi), gutter-1)) case r == height-1: - label = x.c(TempStyle(x.celsius(lo)), PadLeft(fmt.Sprintf("%d°", Deg(lo)), 4)) + label = x.c(TempStyle(x.celsius(lo)), PadLeft(axisLabel(lo), gutter-1)) case height >= 5 && r == height/2: - label = x.c(TempStyle(x.celsius(value)), PadLeft(fmt.Sprintf("%d°", Deg(value)), 4)) + label = x.c(TempStyle(x.celsius(value)), PadLeft(axisLabel(value), gutter-1)) } cells := make([]Cell, 0, len(drawn)) for _, d := range drawn { @@ -109,7 +118,7 @@ func (x ctx) chart(v View) []string { out = append(out, label+x.c(Dim, "│")+Paint(cells, x.c)) } - note := fmt.Sprintf("%d-%d°", Deg(lo), Deg(hi)) + note := axisLabel(lo) + "-" + axisLabel(hi) if len(groups) < len(v.Rows) { note += fmt.Sprintf(" %.0f%s", float64(len(v.Rows))/float64(len(groups)), x.cat.Word("h_per_col")) } @@ -124,7 +133,7 @@ func (x ctx) chart(v View) []string { for i, d := range drawn { series[i] = d.rain } - out = append(out, x.c(Dim, PadLeft(x.cat.Word("rain_row"), 4)+"│")+ + out = append(out, x.c(Dim, PadLeft(x.cat.Word("rain_row"), gutter-1)+"│")+ x.c(Cyan, spark(series))+ x.c(Dim, fmt.Sprintf(" %s %.1fmm", x.cat.Word("max"), maxRain))) } @@ -203,3 +212,27 @@ func maxInt(a, b int) int { func ceilDiv(a, b int) int { return (a + b - 1) / b } var _ = openmeteo.Row{} + +// minSpanForWholeDegrees is the range below which whole-degree axis labels stop +// distinguishing rows. With five rows a 2-degree span puts adjacent labels half +// a degree apart, which still rounds to distinct values; below that they +// collide. +const minSpanForWholeDegrees = 2.0 + +// tempLabeller returns a formatter for the y-axis, choosing precision from the +// range so that labels stay distinct, and never exceeding maxWidth cells. +func tempLabeller(lo, hi float64, maxWidth int) func(float64) string { + whole := func(v float64) string { return fmt.Sprintf("%d°", Deg(v)) } + if hi-lo >= minSpanForWholeDegrees { + return whole + } + decimal := func(v float64) string { return fmt.Sprintf("%.1f°", v) } + // A decimal label is two characters longer; if that will not fit, whole + // degrees are wrong but legible, which beats a sheared column. + for _, v := range []float64{lo, hi} { + if DisplayWidth(decimal(v)) > maxWidth { + return whole + } + } + return decimal +} diff --git a/internal/render/render_test.go b/internal/render/render_test.go index d06fc7b..4879855 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -403,3 +403,86 @@ func TestNoSpeciesIsPrivileged(t *testing.T) { t.Errorf("grass at zero must be dropped like any other species:\n%s", out) } } + +// A narrow range cannot be labelled in whole degrees: rounding makes adjacent +// rows read the same, so the axis claims two different temperatures are equal. +func TestNarrowRangeGetsDecimalAxisLabels(t *testing.T) { + cfg := testConfig() + cfg.Graph = true + var rows []openmeteo.Row + for i, temp := range []float64{15.7, 16.0, 16.0, 15.2, 14.8, 15.1, 15.2, 15.2} { + r := row(0, temp, 3, 0, 0) + r.When = time.Date(2026, 8, 25, 15+i, 0, 0, 0, time.UTC) + rows = append(rows, r) + } + out := Render(view(rows...), cfg, 80, false) + + var labels []string + for _, l := range strings.Split(out, "\n") { + if i := strings.Index(l, "│"); i > 0 { + if lbl := strings.TrimSpace(l[:i]); lbl != "" && lbl != "rain" { + labels = append(labels, lbl) + } + } + } + if len(labels) < 3 { + t.Fatalf("expected three axis labels, got %v\n%s", labels, out) + } + seen := map[string]bool{} + for _, l := range labels { + if seen[l] { + t.Errorf("axis label %q repeats; a 1.2 degree span needs decimals:\n%s", l, out) + } + seen[l] = true + if !strings.Contains(l, ".") { + t.Errorf("label %q should carry a decimal for a narrow range", l) + } + } +} + +// A wide range stays in whole degrees: decimals there are noise. +func TestWideRangeKeepsWholeDegreeLabels(t *testing.T) { + cfg := testConfig() + cfg.Graph = true + var rows []openmeteo.Row + for i, temp := range []float64{5, 12, 18, 24, 28, 22, 14, 7} { + r := row(0, temp, 3, 0, 0) + r.When = time.Date(2026, 8, 25, 15+i, 0, 0, 0, time.UTC) + rows = append(rows, r) + } + out := Render(view(rows...), cfg, 80, false) + for _, l := range strings.Split(out, "\n") { + if i := strings.Index(l, "│"); i > 0 { + lbl := strings.TrimSpace(l[:i]) + if strings.Contains(lbl, ".") { + t.Errorf("wide range should use whole degrees, got %q", lbl) + } + } + } +} + +func TestAxisLabelPrecisionAndWidth(t *testing.T) { + cases := []struct { + lo, hi float64 + maxWidth int + wantDot bool + why string + }{ + {14.8, 16.0, 5, true, "1.2 degrees needs decimals"}, + {12, 28, 5, false, "16 degrees does not"}, + {15, 17, 5, false, "exactly the threshold stays whole"}, + {-20.4, -19.6, 5, false, "decimals would not fit, so fall back"}, + } + for _, c := range cases { + f := tempLabeller(c.lo, c.hi, c.maxWidth) + got := f(c.lo) + if strings.Contains(got, ".") != c.wantDot { + t.Errorf("lo=%v hi=%v gave %q; %s", c.lo, c.hi, got, c.why) + } + for _, v := range []float64{c.lo, c.hi} { + if w := DisplayWidth(f(v)); w > c.maxWidth { + t.Errorf("label %q is %d cells, over the %d-cell gutter", f(v), w, c.maxWidth) + } + } + } +} |
