aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md9
-rw-r--r--README.md22
-rw-r--r--internal/render/chart.go47
-rw-r--r--internal/render/render_test.go83
4 files changed, 143 insertions, 18 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e38faf9..63ba241 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,15 @@
Terse, newest first. Versions are git tags.
+## 0.1.3 — unreleased
+
+### Fixed
+- Chart axis labels no longer repeat on a narrow range. A 1.2 degree span
+ rounded to whole degrees put "15" against two different rows, so the axis
+ said they were the same temperature. Below a 2 degree span the labels carry
+ one decimal, falling back to whole degrees where a decimal would not fit the
+ gutter.
+
## 0.1.2 — 2026-08-25
### Changed
diff --git a/README.md b/README.md
index 50ce8fd..1d79322 100644
--- a/README.md
+++ b/README.md
@@ -13,26 +13,26 @@ one creeps in.
```
Krakow, PL Tue 25 Aug 05:44 up 19:39 down GMT+2
day 12-17° rain 1.9mm over 7h sun 0h43m of 13h54m daylight
- pollen ragweed 6.9 mugwort 4.4 low grass 3.1 low birch 0.0 none
+ pollen ragweed 5.0 mugwort 4.4 low grass 3.1 low birch 0.0 none
hr temp feels conditions hum mm rain
- 15 16° lt drizzle 79% 0.3 70%
- 16 16° 80% 0.3 44%
+ 16 16° lt drizzle 80% 0.3 44%
17 16° overcast 77% 0.0 27%
18 16° lt drizzle 78% 0.1 27%
19 15° 86% 0.2 36%
20 15° 87% 0.4 45%
21 15° 85% 0.2 52%
22 15° (14) overcast 80% 0.0 58%
+ 23 15° 78% 0.0 59%
- 16°│ ████████████████
- │████████▄▄▄▄▄▄▄▄████████████████
- 15°│████████████████████████████████
- │████████████████████████████████▄▄▄▄▄▄▄▄ ████████
- 15°│████████████████████████████████████████▄▄▄▄▄▄▄▄████████████████
-rain│▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▁▁▁▁▁▁▁▁▃▃▃▃▃▃▃▃▅▅▅▅▅▅▅▅████████▅▅▅▅▅▅▅▅▁▁▁▁▁▁▁▁ max 0.4mm
- 15 16 17 18 19 20 21 22
- 15-16°
+15.9°│ ████████████████
+ │▄▄▄▄▄▄▄▄████████████████
+15.2°│████████████████████████ ▄▄▄▄▄▄▄▄
+ │████████████████████████▄▄▄▄▄▄▄▄ ████████████████
+14.6°│████████████████████████████████▄▄▄▄▄▄▄▄████████████████████████
+ rain│▆▆▆▆▆▆▆▆▁▁▁▁▁▁▁▁▃▃▃▃▃▃▃▃▅▅▅▅▅▅▅▅████████▅▅▅▅▅▅▅▅▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ max 0.4mm
+ 16 17 18 19 20 21 22 23
+ 14.6°-15.9°
```
When IMGW has issued a warning for your powiat, it leads the output:
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)
+ }
+ }
+ }
+}