-
Notifications
You must be signed in to change notification settings - Fork 205
plotter: add vector field plotter #543
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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) | ||
|
||
// 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() | ||
sbinet marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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 { | ||
sbinet marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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 | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,147 @@ | ||
// Copyright ©2019 The Gonum Authors. All rights reserved. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
// 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") | ||
} |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.