From 760b68cd8c6b7239a3f6cff2a72d5b2a2eee7cce Mon Sep 17 00:00:00 2001 From: Chris Tessum Date: Sat, 9 Jul 2016 08:30:22 -0700 Subject: [PATCH] Added ColorMap types and legends --- palette/legend.go | 118 ++++++++++ palette/legend_test.go | 55 +++++ palette/legendcategorical.go | 203 ++++++++++++++++++ palette/legendcategorical_test.go | 163 ++++++++++++++ palette/palette.go | 71 ++++++ .../colorMapLegendHorizontal_golden.png | Bin 0 -> 1719 bytes .../colorMapLegendVertical_golden.png | Bin 0 -> 2309 bytes .../intMapLegendHorizontal_golden.png | Bin 0 -> 1155 bytes .../testdata/intMapLegendVertical_golden.png | Bin 0 -> 1274 bytes .../stringMapLegendHorizontal_golden.png | Bin 0 -> 2772 bytes .../stringMapLegendVertical_golden.png | Bin 0 -> 3145 bytes 11 files changed, 610 insertions(+) create mode 100644 palette/legend.go create mode 100644 palette/legend_test.go create mode 100644 palette/legendcategorical.go create mode 100644 palette/legendcategorical_test.go create mode 100644 palette/testdata/colorMapLegendHorizontal_golden.png create mode 100644 palette/testdata/colorMapLegendVertical_golden.png create mode 100644 palette/testdata/intMapLegendHorizontal_golden.png create mode 100644 palette/testdata/intMapLegendVertical_golden.png create mode 100644 palette/testdata/stringMapLegendHorizontal_golden.png create mode 100644 palette/testdata/stringMapLegendVertical_golden.png 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 0000000000000000000000000000000000000000..0f3f9dd996474beae201726bb3e773ac2e83bbac GIT binary patch literal 1719 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 V@JRE<`PTpd002ovPDHLkV1l*2A&US2 literal 0 HcmV?d00001 diff --git a/palette/testdata/intMapLegendVertical_golden.png b/palette/testdata/intMapLegendVertical_golden.png new file mode 100644 index 0000000000000000000000000000000000000000..af746adf50706d9e97b802f394f16232d3245976 GIT binary patch literal 1274 zcmVzi(#W_n)DJkLDOr%5Cl zjY5AP5{dN8(SH~i^NgjXrDz(NpP$DzUgA2kMkX4ONMvJUL$B9|!(l>cSS;X;uC6Y#+3avQ zAP6ccDLFnqzQ4Z*Yam=M*Jv~N(4b@G#Z6M0c=1J)c!RP1nKqq*49=8LBJZY z*XzB#y^S|^cXvf15m+F09VaIzYinzLeSKQ3mO`O)cXxv~YHMqKKA**686F;Hu~@)? zm_eh_h`2wg$&@t_dzvmUFQ)^2=KL~J~mOlBN_AV{TB4Gau` z1)hw#!{P9FJk{0J1R83!TB%e52fi6jr_*k?*VWY#XsFd{g+f6DkT6^>*U`~YOG^uZ z28yB+6B7ghiQ1>4qJlUelh#DcGk*Q-r{?1SRn|ZIF=`+24?^6%CxcP@JU%{#LLp)e zAi)?9VM~i?dpqyV8{EJ{Ab?#be#kFsFJRy{e(}1o=hcrz|4YZ9kl;*t2D8TNGGcSr zU@!!O!FZehHQ)`6Mw6@-SS%JHMnglx)YKH%epB;O<8gX=Ix;d6i9|RYj#MgTGMV6w z3v@aiyg{W>g+c(u$1};KHSzg;^54?n9NfUR z{}!W8{Lu6sckjty)IQ;GIQ>WR71W7sZDRAzCo+YK8wiDh*mYuFZe2dL3Af=3Y-7)> z!Z+>d7%+*JDbHZmcwGh(|EIIvZZ9h<69@!_g@wQk*zI=fbUL9>NT<{JeE#0v9(aRJ zr;m<~a=BcQNFCb0F_H!D<>e)Z!vSt2){>c-8I#GxO#C?!m!m>n^;%zwdUt<82g0^YZe5e2yqJ@WsW& z4~` z84uwvIq+NDz(eQ@Mx9t+{~hk$lfkHc0)c?n>m}9z5{&Wa=;$z;&B>(&M?#?xcAbd3 z_hd5V`N$goWy5SXC*LXpPBa>|*=%yT9D<;mo0~taiNK7*!^69~J7SG!G>W1qaYp9N ztBf$>AA?MPTm8$#_Ndj>)$8l)c$>js$Yio_he7O7*?sU|W$<{s^bL$LM^V({@epeO k$txI-tdWvG0ssL2|F{9cpKPpBxBvhE07*qoM6N<$fb%7 literal 0 HcmV?d00001 diff --git a/palette/testdata/stringMapLegendHorizontal_golden.png b/palette/testdata/stringMapLegendHorizontal_golden.png new file mode 100644 index 0000000000000000000000000000000000000000..08116241cabd658063c9690ec50e36a4b59947da GIT binary patch literal 2772 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 hzft={3IsYkI}CVx@mWVoHSmi7GSV~Et