Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 196 additions & 0 deletions plotter/field.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
// Copyright ©2019 The Gonum Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package plotter

import (
"math"

"gonum.org/v1/plot"
"gonum.org/v1/plot/vg"
"gonum.org/v1/plot/vg/draw"
)

// FieldXY describes a two dimensional vector field where the
// X and Y coordinates are arranged on a rectangular grid.
type FieldXY interface {
// Dims returns the dimensions of the grid.
Dims() (c, r int)

// Vector returns the value of a vector field at (c, r).
// It will panic if c or r are out of bounds for the field.
Vector(c, r int) XY

// X returns the coordinate for the column at the index c.
// It will panic if c is out of bounds for the grid.
X(c int) float64

// Y returns the coordinate for the row at the index r.
// It will panic if r is out of bounds for the grid.
Y(r int) float64
}

// Field implements the Plotter interface, drawing
// a vector field of the values in the FieldXY field.
type Field struct {
FieldXY FieldXY

// DrawGlyph is the user hook to draw a field
// vector glyph. The function should draw a unit
// vector to (1, 0) on the vg.Canvas, c.
// The direction and magnitude of v can be used
// to determine properties of the glyph drawing
// but should not be used to determine size or
// directions of the glyph.
//
// If DrawGlyph is nil, a simple arrow will be
// drawn.
DrawGlyph func(c vg.Canvas, v XY)
Copy link
Contributor

@ctessum ctessum Apr 4, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if a user wants to change the color of the glyph based on the field magnitude? What if a user wants to scale the size of the glyph based on the square root of magnitude, or some other transform?

I think both of these things would be allowed if we made the field magnitude an argument here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or maybe the normalized magnitude.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or, actually, providing the row and column indexes as arguments would probably be better, because then the user can pull in whatever external information they need.

Copy link
Member Author

@kortschak kortschak Apr 4, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the user wants to change the colour based on the magnitude, they can calculate the magnitude from v. This was the major reason v was included (which is the normalised vector).

I don't like the idea of a completely free approach to transforms. It opens up whole new world of issues to have to answer. This is simple and works for the vast majority of cases with close to zero work for the user. Allowing scaling by other than linear seems to me like an anti-pattern; the two commonly used scalings are length and area (in that order). People are bad at assessing area compared to length (this is a reason why pie charts are bad).

I can see some merits in providing the row/column, but I need to think about it some more. I'm not sure the additional complexity wins much.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I somehow missed the fact that v was already included as an argument.


// LineStyle is the style of the line used to
// render vectors if DrawGlyph is nil.
LineStyle draw.LineStyle

// max define the dynamic range of the field.
max float64
}

// NewField creates a new vector field plotter.
func NewField(f FieldXY) *Field {
max := math.Inf(-1)
c, r := f.Dims()
for i := 0; i < c; i++ {
for j := 0; j < r; j++ {
v := f.Vector(i, j)
d := math.Hypot(v.X, v.Y)
if math.IsNaN(d) {
continue
}
max = math.Max(max, d)
}
}

return &Field{
FieldXY: f,
LineStyle: DefaultLineStyle,
max: max,
}
}

// Plot implements the Plot method of the plot.Plotter interface.
func (f *Field) Plot(c draw.Canvas, plt *plot.Plot) {
c.Push()
defer c.Pop()
c.SetLineStyle(f.LineStyle)

trX, trY := plt.Transforms(&c)

cols, rows := f.FieldXY.Dims()
for i := 0; i < cols; i++ {
var right, left float64
switch i {
case 0:
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
left = -right
default:
right = (f.FieldXY.X(i+1) - f.FieldXY.X(i)) / 2
left = -(f.FieldXY.X(i) - f.FieldXY.X(i-1)) / 2
}

for j := 0; j < rows; j++ {
var up, down float64
switch j {
case 0:
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
down = -up
default:
up = (f.FieldXY.Y(j+1) - f.FieldXY.Y(j)) / 2
down = -(f.FieldXY.Y(j) - f.FieldXY.Y(j-1)) / 2
}

x, y := trX(f.FieldXY.X(i)+left), trY(f.FieldXY.Y(j)+down)
dx, dy := trX(f.FieldXY.X(i)+right), trY(f.FieldXY.Y(j)+up)

if !c.Contains(vg.Point{X: x, Y: y}) || !c.Contains(vg.Point{X: dx, Y: dy}) {
continue
}

c.Push()
c.Translate(vg.Point{X: (x + dx) / 2, Y: (y + dy) / 2})
v := f.FieldXY.Vector(i, j)
c.Rotate(math.Atan2(v.Y, v.X))
s := math.Hypot(v.X, v.Y) / (2 * f.max)
v.X *= s
v.Y *= s
c.Scale(s*float64(dx-x), s*float64(dy-y))
if f.DrawGlyph == nil {
drawVector(c, v)
} else {
f.DrawGlyph(c, v)
}
c.Pop()
}
}
}

func drawVector(c vg.Canvas, v XY) {
if math.Hypot(v.X, v.Y) == 0 {
return
}
// TODO(kortschak): Improve this arrow.
var pa vg.Path
pa.Move(vg.Point{})
pa.Line(vg.Point{X: 1, Y: 0})
pa.Close()
c.Stroke(pa)
}

// DataRange implements the DataRange method
// of the plot.DataRanger interface.
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
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
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
}
return xmin, xmax, ymin, ymax
}

// GlyphBoxes implements the GlyphBoxes method
// of the plot.GlyphBoxer interface.
func (f *Field) GlyphBoxes(plt *plot.Plot) []plot.GlyphBox {
c, r := f.FieldXY.Dims()
b := make([]plot.GlyphBox, 0, r*c)
for i := 0; i < c; i++ {
for j := 0; j < r; j++ {
b = append(b, plot.GlyphBox{
X: plt.X.Norm(f.FieldXY.X(i)),
Y: plt.Y.Norm(f.FieldXY.Y(j)),
Rectangle: vg.Rectangle{
Min: vg.Point{X: -5, Y: -5},
Max: vg.Point{X: +5, Y: +5},
},
})
}
}
return b
}
147 changes: 147 additions & 0 deletions plotter/field_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// Copyright ©2019 The Gonum Authors. All rights reserved.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To make the examples more legible in godoc, it might make sense to split the examples and tests up into individual files. From the documentation:

A whole file example is a file that ends in _test.go and contains exactly one example function, no test or benchmark functions, and at least one other package-level declaration. When displaying such examples godoc will show the entire file.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is another PR. The example code that this is adapted from, heat_test.go is the same. In fact I think they all are because the TestX funcs are in the same files.

// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package plotter_test

import (
"image/png"
"log"
"os"
"testing"

"gonum.org/v1/plot"
"gonum.org/v1/plot/cmpimg"
"gonum.org/v1/plot/plotter"
"gonum.org/v1/plot/vg"
"gonum.org/v1/plot/vg/draw"
"gonum.org/v1/plot/vg/vgimg"
)

type field struct {
c, r int
fn func(x, y float64) plotter.XY
}

func (f field) Dims() (c, r int) { return f.c, f.r }
func (f field) Vector(c, r int) plotter.XY { return f.fn(f.X(c), f.Y(r)) }
func (f field) X(c int) float64 {
if c < 0 || c >= f.c {
panic("column index out of range")
}
return float64(c - f.c/2)
}
func (f field) Y(r int) float64 {
if r < 0 || r >= f.r {
panic("row index out of range")
}
return float64(r - f.r/2)
}

func ExampleField() {
f := plotter.NewField(field{
r: 17, c: 19,
fn: func(x, y float64) plotter.XY {
return plotter.XY{
X: y,
Y: -x,
}
},
})
f.LineStyle.Width = 0.2

p, err := plot.New()
if err != nil {
log.Panic(err)
}
p.Title.Text = "Vector field"

p.X.Tick.Marker = integerTicks{}
p.Y.Tick.Marker = integerTicks{}

p.Add(f)

img := vgimg.New(250, 175)
dc := draw.New(img)

p.Draw(dc)
w, err := os.Create("testdata/field.png")
if err != nil {
log.Panic(err)
}
png := vgimg.PngCanvas{Canvas: img}
if _, err = png.WriteTo(w); err != nil {
log.Panic(err)
}
}

func TestField(t *testing.T) {
cmpimg.CheckPlot(ExampleField, t, "field.png")
}

func ExampleField_gophers() {
file, err := os.Open("testdata/gopher_running.png")
if err != nil {
log.Panic(err)
}
defer file.Close()
gopher, err := png.Decode(file)
if err != nil {
log.Panic(err)
}

f := plotter.NewField(field{
r: 5, c: 9,
fn: func(x, y float64) plotter.XY {
return plotter.XY{
X: -0.75*x + y,
Y: -0.75*y - x,
}
},
})

// Provide a DrawGlyph function to render a custom
// vector glyph instead of the default arrow.
f.DrawGlyph = func(c vg.Canvas, v plotter.XY) {
// Vector glyphs are scaled to half unit length by the
// plotter, so scale the gopher to twice unit size so
// it fits the cell, and center on the cell.
c.Translate(vg.Point{X: -1, Y: -1})
c.DrawImage(vg.Rectangle{Max: vg.Point{X: 2, Y: 2}}, gopher)
}

p, err := plot.New()
if err != nil {
log.Panic(err)
}
p.Title.Text = "Gopher vortex"

p.X.Tick.Marker = integerTicks{}
p.Y.Tick.Marker = integerTicks{}

p.Add(f)

img := vgimg.New(250, 175)
dc := draw.New(img)

p.Draw(dc)
w, err := os.Create("testdata/gopher_field.png")
if err != nil {
log.Panic(err)
}
png := vgimg.PngCanvas{Canvas: img}
if _, err = png.WriteTo(w); err != nil {
log.Panic(err)
}
}

func max(a, b int) int {
if a > b {
return a
}
return b
}

func TestFieldGophers(t *testing.T) {
cmpimg.CheckPlot(ExampleField_gophers, t, "gopher_field.png")
}
Binary file added plotter/testdata/field_golden.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added plotter/testdata/gopher_field_golden.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added plotter/testdata/gopher_running.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.