Repository: gonum/plot (go)

Issue:
plot/plotter: HeatMap panics for single-row/single-column grids
### What are you trying to do?
Create and plot a HeatMap with a grid containing a single column. (I observe the same problem with a grid with a single row.)

### What did you do?
The below is an almost-minimal example, based on heat_test.go. The only relevant change is the change of the input data matrix.
```
package main

import (
	"fmt"
	"log"
	"math"
	"os"

	"gonum.org/v1/gonum/mat"
	"gonum.org/v1/plot"
	"gonum.org/v1/plot/palette"
	"gonum.org/v1/plot/plotter"
	"gonum.org/v1/plot/vg/draw"
	"gonum.org/v1/plot/vg/vgimg"
)

type offsetUnitGrid struct {
	XOffset, YOffset float64

	Data mat.Matrix
}

func (g offsetUnitGrid) Dims() (c, r int)   { r, c = g.Data.Dims(); return c, r }
func (g offsetUnitGrid) Z(c, r int) float64 { return g.Data.At(r, c) }
func (g offsetUnitGrid) X(c int) float64 {
	_, n := g.Data.Dims()
	if c < 0 || c >= n {
		panic("column index out of range")
	}
	return float64(c) + g.XOffset
}
func (g offsetUnitGrid) Y(r int) float64 {
	m, _ := g.Data.Dims()
	if r < 0 || r >= m {
		panic("row index out of range")
	}
	return float64(r) + g.YOffset
}

type integerTicks struct{}

func (integerTicks) Ticks(min, max float64) []plot.Tick {
	var t []plot.Tick
	for i := math.Trunc(min); i <= max; i++ {
		t = append(t, plot.Tick{Value: i, Label: fmt.Sprint(i)})
	}
	return t
}

func ExampleHeatMap() {
	m := offsetUnitGrid{
		XOffset: 0,
		YOffset: 0,
		Data: mat.NewDense(1, 2, []float64{
			1, 2,
		})}
	pal := palette.Heat(12, 1)
	h := plotter.NewHeatMap(m, pal)

	p, err := plot.New()
	if err != nil {
		log.Panic(err)
	}
	p.Title.Text = "Heat map"

	p.X.Tick.Marker = integerTicks{}
	p.Y.Tick.Marker = integerTicks{}

	p.Add(h)

	img := vgimg.New(250, 175)
	dc := draw.New(img)

	p.Draw(dc)
	w, err := os.Create("heatMap.png")
	if err != nil {
		log.Panic(err)
	}
	png := vgimg.PngCanvas{Canvas: img}
	if _, err = png.WriteTo(w); err != nil {
		log.Panic(err)
	}
}

func main() {
	ExampleHeatMap()
}
```

### What did you expect to happen?
I expected a HeatMap with a single column to be produced.

### What actually happened?
Instead, the code panics, telling me:
```
$ go run test_heatmap.go
panic: row index out of range

goroutine 1 [running]:
main.offsetUnitGrid.Y(0x0, 0x0, 0x15259e0, 0xc0000bf640, 0x1, 0x0)
	/tmp/src/test_heatmap.go:35 +0x83
gonum.org/v1/plot/plotter.(*HeatMap).Plot(0xc000082180, 0x152a200, 0xc0000a95f0, 0x4038948000000000, 0x4037108000000000, 0x406f400000000000, 0x4063f2b000000000, 0xc000228400)
	/tmp/src/gonum.org/v1/plot/plotter/heat.go:133 +0xeeb
gonum.org/v1/plot.(*Plot).Draw(0xc000228400, 0x152a080, 0xc0000821e0, 0x0, 0x0, 0x406f400000000000, 0x4063f2b000000000)
	/tmp/src/gonum.org/v1/plot/plot.go:165 +0x92e
main.ExampleHeatMap()
	/tmp/src/test_heatmap.go:74 +0x32a
main.main()
	/tmp/src/test_heatmap.go:86 +0x20
exit status 2
```

The problem originates from this line:
https://github.com/gonum/plot/blob/master/plotter/heat.go#L133
which calls h.GridXYZ.Y(1) for a GridXYZ that only has one column (so max. Y index 0).
(The equivalent problem for single-row grids occurs in this line:
https://github.com/gonum/plot/blob/master/plotter/heat.go#L119.)

In principle this should be easy to fix: add the appropriate special cases for single-column/single-row HeatMaps. The problem I encountered when I tried this, though, is that the GridXYZ interface does not expose the required information about the grid cell sizes.
NOTE: Without claiming to actually understand the code quite yet, this makes me believe that the current implementation is only correct for grids with constant cell sizes(?).

### What version of Go and Gonum/plot are you using?
Go: go version go1.12.2 darwin/amd64
Gonum/plot: 06eadbae218595eae650257c1d591e64364c76d7

### Does this issue reproduce with the current master?
Yes, it does.


Pull request:
plotter: handle cases in HeatMap and Field with single width/height
Please take a look.

Fixes #548.

<!--
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 _____."
-->


Patch:
diff --git a/plotter/field.go b/plotter/field.go
index 1b30422..389c804 100644
--- a/plotter/field.go
+++ b/plotter/field.go
@@ -97,7 +97,11 @@ func (f *Field) Plot(c draw.Canvas, plt *plot.Plot) {
 		var right, left float64
 		switch i {
 		case 0:
-			right = (f.FieldXY.X(1) - f.FieldXY.X(0)) / 2
+			if cols == 1 {
+				right = 0.5
+			} else {
+				right = (f.FieldXY.X(1) - f.FieldXY.X(0)) / 2
+			}
 			left = -right
 		case cols - 1:
 			right = (f.FieldXY.X(cols-1) - f.FieldXY.X(cols-2)) / 2
@@ -111,7 +115,11 @@ func (f *Field) Plot(c draw.Canvas, plt *plot.Plot) {
 			var up, down float64
 			switch j {
 			case 0:
-				up = (f.FieldXY.Y(1) - f.FieldXY.Y(0)) / 2
+				if rows == 1 {
+					up = 0.5
+				} else {
+					up = (f.FieldXY.Y(1) - f.FieldXY.Y(0)) / 2
+				}
 				down = -up
 			case rows - 1:
 				up = (f.FieldXY.Y(rows-1) - f.FieldXY.Y(rows-2)) / 2
@@ -170,16 +178,16 @@ func (f *Field) DataRange() (xmin, xmax, ymin, ymax float64) {
 	c, r := f.FieldXY.Dims()
 	switch c {
 	case 1: // Make a unit length when there is no neighbour.
-		xmax = 0.5
-		xmin = -0.5
+		xmax = f.FieldXY.X(0) + 0.5
+		xmin = f.FieldXY.X(0) - 0.5
 	default:
 		xmax = f.FieldXY.X(c-1) + (f.FieldXY.X(c-1)-f.FieldXY.X(c-2))/2
 		xmin = f.FieldXY.X(0) - (f.FieldXY.X(1)-f.FieldXY.X(0))/2
 	}
 	switch r {
 	case 1: // Make a unit length when there is no neighbour.
-		ymax = 0.5
-		ymin = -0.5
+		ymax = f.FieldXY.Y(0) + 0.5
+		ymin = f.FieldXY.Y(0) - 0.5
 	default:
 		ymax = f.FieldXY.Y(r-1) + (f.FieldXY.Y(r-1)-f.FieldXY.Y(r-2))/2
 		ymin = f.FieldXY.Y(0) - (f.FieldXY.Y(1)-f.FieldXY.Y(0))/2
diff --git a/plotter/heat.go b/plotter/heat.go
index 714f659..c33103e 100644
--- a/plotter/heat.go
+++ b/plotter/heat.go
@@ -116,7 +116,11 @@ func (h *HeatMap) Plot(c draw.Canvas, plt *plot.Plot) {
 		var right, left float64
 		switch i {
 		case 0:
-			right = (h.GridXYZ.X(1) - h.GridXYZ.X(0)) / 2
+			if cols == 1 {
+				right = 0.5
+			} else {
+				right = (h.GridXYZ.X(1) - h.GridXYZ.X(0)) / 2
+			}
 			left = -right
 		case cols - 1:
 			right = (h.GridXYZ.X(cols-1) - h.GridXYZ.X(cols-2)) / 2
@@ -130,7 +134,11 @@ func (h *HeatMap) Plot(c draw.Canvas, plt *plot.Plot) {
 			var up, down float64
 			switch j {
 			case 0:
-				up = (h.GridXYZ.Y(1) - h.GridXYZ.Y(0)) / 2
+				if rows == 1 {
+					up = 0.5
+				} else {
+					up = (h.GridXYZ.Y(1) - h.GridXYZ.Y(0)) / 2
+				}
 				down = -up
 			case rows - 1:
 				up = (h.GridXYZ.Y(rows-1) - h.GridXYZ.Y(rows-2)) / 2
@@ -179,16 +187,16 @@ func (h *HeatMap) DataRange() (xmin, xmax, ymin, ymax float64) {
 	c, r := h.GridXYZ.Dims()
 	switch c {
 	case 1: // Make a unit length when there is no neighbour.
-		xmax = 0.5
-		xmin = -0.5
+		xmax = h.GridXYZ.X(0) + 0.5
+		xmin = h.GridXYZ.X(0) - 0.5
 	default:
 		xmax = h.GridXYZ.X(c-1) + (h.GridXYZ.X(c-1)-h.GridXYZ.X(c-2))/2
 		xmin = h.GridXYZ.X(0) - (h.GridXYZ.X(1)-h.GridXYZ.X(0))/2
 	}
 	switch r {
 	case 1: // Make a unit length when there is no neighbour.
-		ymax = 0.5
-		ymin = -0.5
+		ymax = h.GridXYZ.Y(0) + 0.5
+		ymin = h.GridXYZ.Y(0) - 0.5
 	default:
 		ymax = h.GridXYZ.Y(r-1) + (h.GridXYZ.Y(r-1)-h.GridXYZ.Y(r-2))/2
 		ymin = h.GridXYZ.Y(0) - (h.GridXYZ.Y(1)-h.GridXYZ.Y(0))/2


Test patch:
diff --git a/plotter/field_test.go b/plotter/field_test.go
index 580df43..bed21f6 100644
--- a/plotter/field_test.go
+++ b/plotter/field_test.go
@@ -218,3 +218,44 @@ func ExampleField_gophers() {
 func TestFieldGophers(t *testing.T) {
 	cmpimg.CheckPlot(ExampleField_gophers, t, "gopher_field.png")
 }
+
+func TestFieldDims(t *testing.T) {
+	for _, test := range []struct {
+		rows int
+		cols int
+	}{
+		{rows: 1, cols: 2},
+		{rows: 2, cols: 1},
+		{rows: 2, cols: 2},
+	} {
+		func() {
+			defer func() {
+				r := recover()
+				if r != nil {
+					t.Errorf("unexpected panic for rows=%d cols=%d: %v", test.rows, test.cols, r)
+				}
+			}()
+
+			f := plotter.NewField(field{
+				r: test.rows, c: test.cols,
+				fn: func(x, y float64) plotter.XY {
+					return plotter.XY{
+						X: y,
+						Y: -x,
+					}
+				},
+			})
+
+			p, err := plot.New()
+			if err != nil {
+				t.Errorf("unexpected error: %v", err)
+			}
+			p.Add(f)
+
+			img := vgimg.New(250, 175)
+			dc := draw.New(img)
+
+			p.Draw(dc)
+		}()
+	}
+}
diff --git a/plotter/heat_test.go b/plotter/heat_test.go
index 21efee7..baf9b48 100644
--- a/plotter/heat_test.go
+++ b/plotter/heat_test.go
@@ -129,3 +129,39 @@ func ExampleHeatMap() {
 func TestHeatMap(t *testing.T) {
 	cmpimg.CheckPlot(ExampleHeatMap, t, "heatMap.png")
 }
+
+func TestHeatMapDims(t *testing.T) {
+	pal := palette.Heat(12, 1)
+
+	for _, test := range []struct {
+		rows int
+		cols int
+	}{
+		{rows: 1, cols: 2},
+		{rows: 2, cols: 1},
+		{rows: 2, cols: 2},
+	} {
+		func() {
+			defer func() {
+				r := recover()
+				if r != nil {
+					t.Errorf("unexpected panic for rows=%d cols=%d: %v", test.rows, test.cols, r)
+				}
+			}()
+
+			m := offsetUnitGrid{Data: mat.NewDense(test.rows, test.cols, nil)}
+			h := plotter.NewHeatMap(m, pal)
+
+			p, err := plot.New()
+			if err != nil {
+				t.Errorf("unexpected error: %v", err)
+			}
+			p.Add(h)
+
+			img := vgimg.New(250, 175)
+			dc := draw.New(img)
+
+			p.Draw(dc)
+		}()
+	}
+}