aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-25 16:09:31 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-25 16:09:31 +0200
commitfb1d31fdcd5d5c8b31bb098c34143996d47e618f (patch)
tree41fd165f467a99fef784e05759c71d89605a85ef
parenta7941bb92df67686b018fbb601cbf413ea909d14 (diff)
downloadprognosis-fb1d31fdcd5d5c8b31bb098c34143996d47e618f.tar.gz
prognosis-fb1d31fdcd5d5c8b31bb098c34143996d47e618f.zip
chart: label a narrow axis in decimals
A span of 1.2 degrees rounded to whole degrees printed "15" against both the middle and bottom rows, so the axis asserted that two rows drawn at different heights were the same temperature. It reads as a rendering fault, and the scale becomes useless exactly when the chart is at its most magnified. Below a two degree span the labels carry one decimal. Two degrees is where five rows put adjacent labels half a degree apart, which still rounds apart; below that they collide. A decimal label that would not fit the gutter -- well below freezing, where the minus sign costs a column -- falls back to whole degrees, which is wrong but legible and beats shearing the column. The gutter widens by one to hold "16.0°".
-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)
+ }
+ }
+ }
+}