Repository: gonum/plot
Pull request #493 (2019-01-07 16:10:03)

plot,plotter: properly handle Log-Y scale in Histogram
Fixes gonum/plot#377.

Please take a look.

<!--
Checklist:

- API changes have been discussed
- code is goformated correctly (goimports)
- packages with generated code have had code generation run
- tests pass locally
- linked to relevant issues

Please make sure your commit message summary line and pull request
title match the Go convention; a one-line summary of the change,
prefixed by the primary affected package that should complete the
sentence, "This change modifies Gonum to _____."
-->


eaburns: The golden image has only one labeled tick mark on the y-axis, so it's not possible to tell the scale. Shouldn't there be at least two, or is that a separate but?
eaburns: Sorry for butting in again with little context, but it also looks like the h1 values at -2 don't show in the golden plot either. What happened to them?
sbinet: >  but it also looks like the h1 values at -2 don't show

ah! quite right...
sbinet: the values at -2 are at the bottom.
`Histogram` should probably implement either `GlyphBoxer` or "artificially" pull down the `ymin` value `DataRange` returns... arf!
sbinet: PTAL.

I ended up modifying `Histogram.DataRange`, keeping track of both `ymin` and `ylow`, where `ymin` is the smallest Y value and `ylow` the smallest non-zero Y value.
if `ymin` is zero, `DataRange` returns `0.5*ylow` as `ymin`, to make sure the smallest bin is still displayed.

open to a more robust algorithm, though...
sbinet: @decibelcooper 
> Also, what about adding a flag to Histogram for whether or not to suppress zeros? Perhaps it's better to have this off by default.

yeah... this has bothered me as well.
perhaps we should bite the bullet and just go with introducing a thin `HistogramLogY` type that wraps `Histogram` but has the zero-suppression built-in.

Commit 9ffa89d adds a special mode to `Histogram` to explictly support Y log-scale.
Less invasive but also less automated.

sbinet: ping?
kortschak: I don't understand the rationale for the first commit. We don't import "log" in plot/axis.go (or anywhere in plot).
sbinet: the `func log(...)` function is quite annoying (to me at least) when I sprinkle code with `log.Printf("boo")` statements during my debugging sessions.

I can remove it and send it as part of another PR. (or not.)
kortschak: >I can remove it and send it as part of another PR. (or not.)

SGTM
sbinet: (had to force-push b/c of the `logf` commit removal...)

Patch:
diff --git a/axis.go b/axis.go
index 0083cde..78465e2 100644
--- a/axis.go
+++ b/axis.go
@@ -94,7 +94,7 @@ func makeAxis(orientation bool) (Axis, error) {
 	}
 
 	a := Axis{
-		Min: math.Inf(1),
+		Min: math.Inf(+1),
 		Max: math.Inf(-1),
 		LineStyle: draw.LineStyle{
 			Color: color.Black,
@@ -469,7 +469,7 @@ var _ Ticker = LogTicks{}
 
 // Ticks returns Ticks in a specified range
 func (LogTicks) Ticks(min, max float64) []Tick {
-	if min <= 0 {
+	if min <= 0 || max <= 0 {
 		panic("Values must be greater than 0 for a log scale.")
 	}
 
diff --git a/plotter/histogram.go b/plotter/histogram.go
index 0eb4796..acbbd72 100644
--- a/plotter/histogram.go
+++ b/plotter/histogram.go
@@ -32,6 +32,14 @@ type Histogram struct {
 	// LineStyle is the style of the outline of each
 	// bar of the histogram.
 	draw.LineStyle
+
+	// LogY allows rendering with a log-scaled Y axis.
+	// When enabled, histogram bins with no entries will be discarded from
+	// the histogram's DataRange.
+	// The lowest Y value for the DataRange will be corrected to leave an
+	// arbitrary amount of height for the smallest bin entry so it is visible
+	// on the final plot.
+	LogY bool
 }
 
 // NewHistogram returns a new histogram
@@ -77,25 +85,34 @@ func (h *Histogram) Plot(c draw.Canvas, p *plot.Plot) {
 	trX, trY := p.Transforms(&c)
 
 	for _, bin := range h.Bins {
+		ymin := c.Min.Y
+		ymax := c.Min.Y
+		if 0 != bin.Weight {
+			ymax = trY(bin.Weight)
+		}
+		xmin := trX(bin.Min)
+		xmax := trX(bin.Max)
 		pts := []vg.Point{
-			{trX(bin.Min), trY(0)},
-			{trX(bin.Max), trY(0)},
-			{trX(bin.Max), trY(bin.Weight)},
-			{trX(bin.Min), trY(bin.Weight)},
+			{xmin, ymin},
+			{xmax, ymin},
+			{xmax, ymax},
+			{xmin, ymax},
 		}
 		if h.FillColor != nil {
 			c.FillPolygon(h.FillColor, c.ClipPolygonXY(pts))
 		}
-		pts = append(pts, vg.Point{X: trX(bin.Min), Y: trY(0)})
+		pts = append(pts, vg.Point{X: xmin, Y: ymin})
 		c.StrokeLines(h.LineStyle, c.ClipLinesXY(pts)...)
 	}
 }
 
 // DataRange returns the minimum and maximum X and Y values
 func (h *Histogram) DataRange() (xmin, xmax, ymin, ymax float64) {
-	xmin = math.Inf(1)
+	xmin = math.Inf(+1)
 	xmax = math.Inf(-1)
+	ymin = math.Inf(+1)
 	ymax = math.Inf(-1)
+	ylow := math.Inf(+1) // ylow will hold the smallest non-zero y value.
 	for _, bin := range h.Bins {
 		if bin.Max > xmax {
 			xmax = bin.Max
@@ -106,6 +123,21 @@ func (h *Histogram) DataRange() (xmin, xmax, ymin, ymax float64) {
 		if bin.Weight > ymax {
 			ymax = bin.Weight
 		}
+		if bin.Weight < ymin {
+			ymin = bin.Weight
+		}
+		if bin.Weight != 0 && bin.Weight < ylow {
+			ylow = bin.Weight
+		}
+	}
+	switch h.LogY {
+	case true:
+		if ymin == 0 && !math.IsInf(ylow, +1) {
+			// Reserve a bit of space for the smallest bin to be displayed still.
+			ymin = ylow * 0.5
+		}
+	default:
+		ymin = 0
 	}
 	return
 }


Test patch:
diff --git a/plotter/histogram_test.go b/plotter/histogram_test.go
index b3450f7..7785ae9 100644
--- a/plotter/histogram_test.go
+++ b/plotter/histogram_test.go
@@ -94,3 +94,51 @@ func TestSingletonHistogram(t *testing.T) {
 	case <-done:
 	}
 }
+
+func ExampleHistogram_logScaleY() {
+	p, err := plot.New()
+	if err != nil {
+		log.Panic(err)
+	}
+	p.Title.Text = "Histogram in log-y"
+	p.Y.Scale = plot.LogScale{}
+	p.Y.Tick.Marker = plot.LogTicks{}
+	p.Y.Label.Text = "Y"
+	p.X.Label.Text = "X"
+
+	h1, err := NewHist(Values{
+		-2, -2,
+		-1,
+		+3, +3, +3, +3,
+		+1, +1, +1, +1, +1, +1, +1, +1, +1, +1,
+		+1, +1, +1, +1, +1, +1, +1, +1, +1, +1,
+	}, 16)
+	if err != nil {
+		log.Fatal(err)
+	}
+	h1.LogY = true
+	h1.FillColor = color.RGBA{255, 0, 0, 255}
+
+	h2, err := NewHist(Values{
+		-3, -3, -3,
+		+2, +2, +2, +2, +2,
+	}, 16)
+
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	h2.LogY = true
+	h2.FillColor = color.RGBA{0, 0, 255, 255}
+
+	p.Add(h1, h2, NewGrid())
+
+	err = p.Save(200, 200, "testdata/histogram_logy.png")
+	if err != nil {
+		log.Fatal(err)
+	}
+}
+
+func TestHistogramLogScale(t *testing.T) {
+	cmpimg.CheckPlot(ExampleHistogram_logScaleY, t, "histogram_logy.png")
+}