diff --git a/palette/legend.go b/palette/legend.go new file mode 100644 index 00000000..2ce731c1 --- /dev/null +++ b/palette/legend.go @@ -0,0 +1,118 @@ +// Copyright ©2016 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 palette + +import ( + "image" + + "github.com/gonum/plot" + "github.com/gonum/plot/vg" + "github.com/gonum/plot/vg/draw" +) + +// ColorMapLegend is a plot.Plotter that draws a color bar legend for a ColorMap. +type ColorMapLegend struct { + // Vertical determines wether the legend will be + // plotted vertically or horizontally. + // The default is false (horizontal). + Vertical bool + + // NColors specifies the number of colors to be + // shown in the legend. The default is 255. + NColors int + + cm ColorMap +} + +// NewColorMapLegend creates a new legend plotter. +func NewColorMapLegend(cm ColorMap) *ColorMapLegend { + return &ColorMapLegend{ + cm: cm, + NColors: 255, + } +} + +// Plot implements the Plot method of the plot.Plotter interface. +func (l *ColorMapLegend) Plot(c draw.Canvas, p *plot.Plot) { + if l.cm.Max() == l.cm.Min() { + panic("palette: ColorMap Max==Min") + } + var img *image.NRGBA64 + var xmin, xmax, ymin, ymax vg.Length + if l.Vertical { + trX, trY := p.Transforms(&c) + xmin = trX(l.cm.Min()) + ymin = trY(0) + xmax = trX(l.cm.Max()) + ymax = trY(1) + img = image.NewNRGBA64(image.Rectangle{ + Min: image.Point{X: 0, Y: 0}, + Max: image.Point{X: 1, Y: l.NColors}, + }) + for i := 0; i < l.NColors; i++ { + color, err := l.cm.At(float64(i) / float64(l.NColors-1)) + if err != nil { + panic(err) + } + if l.Vertical { + img.Set(0, l.NColors-1-i, color) + } else { + img.Set(0, i, color) + } + } + } else { + trX, trY := p.Transforms(&c) + ymin = trY(l.cm.Min()) + xmin = trX(0) + ymax = trY(l.cm.Max()) + xmax = trX(1) + img = image.NewNRGBA64(image.Rectangle{ + Min: image.Point{X: 0, Y: 0}, + Max: image.Point{X: l.NColors, Y: 1}, + }) + for i := 0; i < l.NColors; i++ { + color, err := l.cm.At(float64(i) / float64(l.NColors-1)) + if err != nil { + panic(err) + } + img.Set(i, 0, color) + } + } + rect := vg.Rectangle{ + Min: vg.Point{X: xmin, Y: ymin}, + Max: vg.Point{X: xmax, Y: ymax}, + } + c.DrawImage(rect, img) +} + +// DataRange implements the DataRange method +// of the plot.DataRanger interface. +func (l *ColorMapLegend) DataRange() (xmin, xmax, ymin, ymax float64) { + if l.cm.Max() == l.cm.Min() { + panic("palette: ColorMap Max==Min") + } + if l.Vertical { + return 0, 1, l.cm.Min(), l.cm.Max() + } + return l.cm.Min(), l.cm.Max(), 0, 1 +} + +// SetupPlot changes the default settings of p so that +// they are appropriate for plotting a color bar legend. +func (l *ColorMapLegend) SetupPlot(p *plot.Plot) { + if l.Vertical { + p.HideX() + p.Y.Padding = 0 + } else { + p.HideY() + p.X.Padding = 0 + } +} + +// GlyphBoxes implements the GlyphBoxes method +// of the plot.GlyphBoxer interface. +func (l *ColorMapLegend) GlyphBoxes(plt *plot.Plot) []plot.GlyphBox { + return nil +} diff --git a/palette/legend_test.go b/palette/legend_test.go new file mode 100644 index 00000000..c7787e34 --- /dev/null +++ b/palette/legend_test.go @@ -0,0 +1,55 @@ +// Copyright ©2016 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 palette + +import ( + "log" + "testing" + + "github.com/gonum/plot" + "github.com/gonum/plot/internal/cmpimg" + "github.com/gonum/plot/palette/moreland" +) + +func ExampleColorMapLegend_horizontal() { + p, err := plot.New() + if err != nil { + log.Panic(err) + } + cm := moreland.ExtendedBlackBody() + cm.SetMax(1) + l := NewColorMapLegend(cm) + p.Add(l) + l.SetupPlot(p) + + if err = p.Save(300, 40, "testdata/colorMapLegendHorizontal.png"); err != nil { + log.Panic(err) + } +} + +func TestColorMapLegend_horizontal(t *testing.T) { + cmpimg.CheckPlot(ExampleColorMapLegend_horizontal, t, "colorMapLegendHorizontal.png") +} + +func ExampleColorMapLegend_vertical() { + p, err := plot.New() + if err != nil { + log.Panic(err) + } + cm := moreland.ExtendedBlackBody() + cm.SetMax(1) + l := NewColorMapLegend(cm) + l.Vertical = true + p.Add(l) + l.SetupPlot(p) + + if err = p.Save(40, 300, "testdata/colorMapLegendVertical.png"); err != nil { + log.Panic(err) + } +} + +func TestColorMapLegend_vertical(t *testing.T) { + cmpimg.CheckPlot(ExampleColorMapLegend_vertical, t, "colorMapLegendVertical.png") +} diff --git a/palette/legendcategorical.go b/palette/legendcategorical.go new file mode 100644 index 00000000..2b3741b0 --- /dev/null +++ b/palette/legendcategorical.go @@ -0,0 +1,203 @@ +// Copyright ©2016 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 palette + +import ( + "fmt" + "image/color" + + "github.com/gonum/plot" + "github.com/gonum/plot/vg" + "github.com/gonum/plot/vg/draw" +) + +var ( + // DefaultLineStyle is the default style for drawing + // lines. + DefaultLineStyle = draw.LineStyle{ + Color: color.Black, + Width: vg.Points(1), + Dashes: []vg.Length{}, + DashOffs: 0, + } +) + +// Legend is a plot.Plotter that draws a legend for a Palette. +type Legend struct { + p []color.Color + + // Width is the width of each color rectangle. + Width vg.Length + + // LineStyle is the style of the outline of the rectangles. + draw.LineStyle + + // Offset is added to the X location of each rectangle. + // When the Offset is zero, the rectangles are drawn + // centered at their X location. + Offset vg.Length + + // XMin is the X location of the first rectangle. XMin + // can be changed to move the legend + // down the X axis in order to make grouped + // legends. + XMin float64 + + // Horizontal determines wether the legend will be + // plotted horizontally or vertically. + // The default is false (vertical). + Horizontal bool +} + +// NewLegend creates a new legend plotter. +func NewLegend(p Palette, width vg.Length) *Legend { + return &Legend{ + p: p.Colors(), + Width: width, + LineStyle: DefaultLineStyle, + } +} + +// Plot implements the Plot method of the plot.Plotter interface. +func (l *Legend) Plot(c draw.Canvas, plt *plot.Plot) { + trCat, trVal := plt.Transforms(&c) + if !l.Horizontal { + trCat, trVal = trVal, trCat + } + + for i, rectColor := range l.p { + catVal := l.XMin + float64(i) + catMin := trCat(float64(catVal)) + if l.Horizontal { + if !c.ContainsX(catMin) { + continue + } + } else { + if !c.ContainsY(catMin) { + continue + } + } + catMin = catMin - l.Width/2 + l.Offset + catMax := catMin + l.Width + valMin := trVal(0) + valMax := trVal(1) + + var pts []vg.Point + var poly []vg.Point + if l.Horizontal { + pts = []vg.Point{ + {X: catMin, Y: valMin}, + {X: catMin, Y: valMax}, + {X: catMax, Y: valMax}, + {X: catMax, Y: valMin}, + } + poly = c.ClipPolygonY(pts) + } else { + pts = []vg.Point{ + {X: valMin, Y: catMin}, + {X: valMin, Y: catMax}, + {X: valMax, Y: catMax}, + {X: valMax, Y: catMin}, + } + poly = c.ClipPolygonX(pts) + } + c.FillPolygon(rectColor, poly) + + var outline [][]vg.Point + if l.Horizontal { + pts = append(pts, vg.Point{X: catMin, Y: valMin}) + outline = c.ClipLinesY(pts) + } else { + pts = append(pts, vg.Point{X: valMin, Y: catMin}) + outline = c.ClipLinesX(pts) + } + c.StrokeLines(l.LineStyle, outline...) + } +} + +// DataRange implements the plot.DataRanger interface. +func (l *Legend) DataRange() (xmin, xmax, ymin, ymax float64) { + catMin := l.XMin + catMax := catMin + float64(len(l.p)-1) + + valMin := 0.0 + valMax := 1.0 + if l.Horizontal { + return catMin, catMax, valMin, valMax + } + return valMin, valMax, catMin, catMax +} + +// GlyphBoxes implements the GlyphBoxer interface. +func (l *Legend) GlyphBoxes(plt *plot.Plot) []plot.GlyphBox { + boxes := make([]plot.GlyphBox, len(l.p)) + for i := range boxes { + cat := l.XMin + float64(i) + if l.Horizontal { + boxes[i].X = plt.X.Norm(cat) + boxes[i].Rectangle = vg.Rectangle{ + Min: vg.Point{X: l.Offset - l.Width/2}, + Max: vg.Point{X: l.Offset + l.Width/2}, + } + } else { + boxes[i].Y = plt.Y.Norm(cat) + boxes[i].Rectangle = vg.Rectangle{ + Min: vg.Point{Y: l.Offset - l.Width/2}, + Max: vg.Point{Y: l.Offset + l.Width/2}, + } + } + } + return boxes +} + +// Legend creates a Legend plotter for this StringMap. +func (sm *StringMap) Legend(width vg.Length) *Legend { + return &Legend{ + p: sm.Colors, + Width: width, + LineStyle: DefaultLineStyle, + } +} + +// Legend creates a Legend plotter for this IntMap. +func (im *IntMap) Legend(width vg.Length) *Legend { + return &Legend{ + p: im.Colors, + Width: width, + LineStyle: DefaultLineStyle, + } +} + +// SetupPlot changes the default settings of p so that +// they are appropriate for plotting a legend. +func (sm *StringMap) SetupPlot(l *Legend, p *plot.Plot) { + if !l.Horizontal { + p.HideX() + p.Y.Padding = 0 + p.NominalY(sm.Categories...) + } else { + p.HideY() + p.X.Padding = 0 + p.NominalX(sm.Categories...) + } +} + +// SetupPlot changes the default settings of p so that +// they are appropriate for plotting a legend. +func (im *IntMap) SetupPlot(l *Legend, p *plot.Plot) { + cats := make([]string, len(im.Categories)) + for i, c := range im.Categories { + cats[i] = fmt.Sprintf("%d", c) + } + if !l.Horizontal { + p.HideX() + p.Y.Padding = 0 + p.NominalY(cats...) + } else { + p.HideY() + p.X.Padding = 0 + p.NominalX(cats...) + } +} diff --git a/palette/legendcategorical_test.go b/palette/legendcategorical_test.go new file mode 100644 index 00000000..4e54b97d --- /dev/null +++ b/palette/legendcategorical_test.go @@ -0,0 +1,163 @@ +// Copyright ©2016 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 palette + +import ( + "image/color" + "log" + "reflect" + "testing" + + "github.com/gonum/plot" + "github.com/gonum/plot/internal/cmpimg" + "github.com/gonum/plot/palette/moreland" + "github.com/gonum/plot/vg" +) + +func ExampleStringMap_Legend_vertical() { + p, err := plot.New() + if err != nil { + log.Panic(err) + } + plte := moreland.ExtendedBlackBody().Palette(5) + cm := StringMap{ + Categories: []string{"Cat 1", "Cat 2", "Cat 3", "Cat 4", "Cat 5"}, + Colors: plte.Colors(), + } + l := cm.Legend(6 * vg.Millimeter) + p.Add(l) + cm.SetupPlot(l, p) + + if err = p.Save(45, 100, "testdata/stringMapLegendVertical.png"); err != nil { + log.Panic(err) + } +} + +func TestStringMap_Legend_vertical(t *testing.T) { + cmpimg.CheckPlot(ExampleStringMap_Legend_vertical, t, "stringMapLegendVertical.png") +} + +func ExampleStringMap_Legend_horizontal() { + p, err := plot.New() + if err != nil { + log.Panic(err) + } + plte := moreland.ExtendedBlackBody().Palette(5) + cm := StringMap{ + Categories: []string{"Cat 1", "Cat 2", "Cat 3", "Cat 4", "Cat 5"}, + Colors: plte.Colors(), + } + l := cm.Legend(6 * vg.Millimeter) + l.Horizontal = true + p.Add(l) + cm.SetupPlot(l, p) + + if err = p.Save(150, 25, "testdata/stringMapLegendHorizontal.png"); err != nil { + log.Panic(err) + } +} + +func TestStringMap_Legend_horizontal(t *testing.T) { + cmpimg.CheckPlot(ExampleStringMap_Legend_horizontal, t, "stringMapLegendHorizontal.png") +} + +func ExampleIntMap_Legend_vertical() { + p, err := plot.New() + if err != nil { + log.Panic(err) + } + plte := moreland.ExtendedBlackBody().Palette(5) + cm := IntMap{ + Categories: []int{1, 3, 5, 7, 9}, + Colors: plte.Colors(), + } + l := cm.Legend(6 * vg.Millimeter) + p.Add(l) + cm.SetupPlot(l, p) + + if err = p.Save(25, 100, "testdata/intMapLegendVertical.png"); err != nil { + log.Panic(err) + } +} + +func TestIntMap_Legend_vertical(t *testing.T) { + cmpimg.CheckPlot(ExampleIntMap_Legend_vertical, t, "intMapLegendVertical.png") +} + +func ExampleIntMap_Legend_horizontal() { + p, err := plot.New() + if err != nil { + log.Panic(err) + } + plte := moreland.ExtendedBlackBody().Palette(5) + cm := IntMap{ + Categories: []int{1, 3, 5, 7, 9}, + Colors: plte.Colors(), + } + l := cm.Legend(6 * vg.Millimeter) + l.Horizontal = true + p.Add(l) + cm.SetupPlot(l, p) + + if err = p.Save(100, 25, "testdata/intMapLegendHorizontal.png"); err != nil { + log.Panic(err) + } +} + +func TestIntMap_Legend_horizontal(t *testing.T) { + cmpimg.CheckPlot(ExampleIntMap_Legend_horizontal, t, "intMapLegendHorizontal.png") +} + +func TestStringMap_At(t *testing.T) { + plte := palette{color.Black, color.White} + cm := &StringMap{ + Categories: []string{"Black", "White"}, + Colors: plte.Colors(), + } + c, err := cm.At("Black") + if err != nil { + t.Error(err) + } + if !reflect.DeepEqual(c, color.Black) { + t.Errorf("color should be black but is %v", c) + } + c, err = cm.At("White") + if err != nil { + t.Error(err) + } + if !reflect.DeepEqual(c, color.White) { + t.Errorf("color should be white but is %v", c) + } + c, err = cm.At("black") + if err == nil { + t.Error("this should cause an error but doesn't") + } +} + +func TestIntMap_At(t *testing.T) { + plte := palette{color.Black, color.White} + cm := &IntMap{ + Categories: []int{6, 8}, + Colors: plte.Colors(), + } + c, err := cm.At(6) + if err != nil { + t.Error(err) + } + if !reflect.DeepEqual(c, color.Black) { + t.Errorf("color should be black but is %v", c) + } + c, err = cm.At(8) + if err != nil { + t.Error(err) + } + if !reflect.DeepEqual(c, color.White) { + t.Errorf("color should be white but is %v", c) + } + c, err = cm.At(0) + if err == nil { + t.Error("this should cause an error but doesn't") + } +} diff --git a/palette/palette.go b/palette/palette.go index 10b98e8a..0821b08f 100644 --- a/palette/palette.go +++ b/palette/palette.go @@ -10,8 +10,10 @@ package palette import ( + "fmt" "image/color" "math" + "sort" ) // Palette is a collection of colors ordered into a palette. @@ -31,6 +33,75 @@ type DivergingPalette interface { CriticalIndex() (low, high int) } +// A ColorMapInt maps an integer category value to a color. +// If there is no mapped color for the given category, an error is returned. +type ColorMapInt interface { + // At returns the color associated with category cat. + At(cat int) (color.Color, error) +} + +// IntMap fulfils the ColorMapInt interface, mapping integer +// categories to colors. +type IntMap struct { + Categories []int + Colors []color.Color +} + +// At fulfils the ColorMapInt interface. +func (im *IntMap) At(cat int) (color.Color, error) { + if len(im.Categories) != len(im.Colors) { + panic(fmt.Errorf("palette: number of categories (%d) != number of colors (%d)", len(im.Categories), len(im.Colors))) + } + if i := sort.SearchInts(im.Categories, cat); i < len(im.Categories) && im.Categories[i] == cat { + return im.Colors[i], nil + } + return nil, fmt.Errorf("palette: category '%d' not found", cat) +} + +// A ColorMapString maps a string category value to a color. +// If there is no mapped color for the given category, an error is returned. +type ColorMapString interface { + // At returns the color associated with category cat. + At(cat string) (color.Color, error) +} + +// StringMap fulfils the ColorMapString interface, mapping integer +// categories to colors. +type StringMap struct { + Categories []string + Colors []color.Color +} + +// At fulfils the ColorMapInt interface. +func (sm *StringMap) At(cat string) (color.Color, error) { + if len(sm.Categories) != len(sm.Colors) { + panic(fmt.Errorf("palette: number of categories (%d) != number of colors (%d)", len(sm.Categories), len(sm.Colors))) + } + if i := sort.SearchStrings(sm.Categories, cat); i < len(sm.Categories) && sm.Categories[i] == cat { + return sm.Colors[i], nil + } + return nil, fmt.Errorf("palette: category '%s' not found", cat) +} + +// A ColorMap maps a scalar value to a color. +// If scalar is outside of the allowed range, an error is returned. +type ColorMap interface { + // At returns the color associated with the value scalar. + At(scalar float64) (color.Color, error) + + // Max returns the current maximum value of the ColorMap. + Max() float64 + + // SetMax sets the maximum value of the ColorMap. + SetMax(float64) + + // Min returns the current minimum value of the ColorMap. + Min() float64 + + // SetMin sets the minimum value of the ColorMap. + SetMin(float64) +} + // Hue represents a hue in HSV color space. Valid Hues are within [0, 1]. type Hue float64 diff --git a/palette/testdata/colorMapLegendHorizontal_golden.png b/palette/testdata/colorMapLegendHorizontal_golden.png new file mode 100644 index 00000000..0f3f9dd9 Binary files /dev/null and b/palette/testdata/colorMapLegendHorizontal_golden.png differ diff --git a/palette/testdata/colorMapLegendVertical_golden.png b/palette/testdata/colorMapLegendVertical_golden.png new file mode 100644 index 00000000..3ce8ac7a Binary files /dev/null and b/palette/testdata/colorMapLegendVertical_golden.png differ diff --git a/palette/testdata/intMapLegendHorizontal_golden.png b/palette/testdata/intMapLegendHorizontal_golden.png new file mode 100644 index 00000000..7ed26287 Binary files /dev/null and b/palette/testdata/intMapLegendHorizontal_golden.png differ diff --git a/palette/testdata/intMapLegendVertical_golden.png b/palette/testdata/intMapLegendVertical_golden.png new file mode 100644 index 00000000..af746adf Binary files /dev/null and b/palette/testdata/intMapLegendVertical_golden.png differ diff --git a/palette/testdata/stringMapLegendHorizontal_golden.png b/palette/testdata/stringMapLegendHorizontal_golden.png new file mode 100644 index 00000000..08116241 Binary files /dev/null and b/palette/testdata/stringMapLegendHorizontal_golden.png differ diff --git a/palette/testdata/stringMapLegendVertical_golden.png b/palette/testdata/stringMapLegendVertical_golden.png new file mode 100644 index 00000000..e0f1ff4e Binary files /dev/null and b/palette/testdata/stringMapLegendVertical_golden.png differ