简介

本文介绍 Go 语言的一个非常强大、好用的绘图库——[plot](https://github.com/gonum/plot)plot内置了很多常用的组件,基本满足日常需求。同时,它也提供了定制化的接口,可以实现我们的个性化需求。plot主要用于将数据可视化,便于我们观察、比较。

快速使用

先安装:

  1. $ go get gonum.org/v1/plot/...

后使用:

  1. package main
  2. import (
  3. "log"
  4. "math/rand"
  5. "gonum.org/v1/plot"
  6. "gonum.org/v1/plot/plotter"
  7. "gonum.org/v1/plot/plotutil"
  8. "gonum.org/v1/plot/vg"
  9. )
  10. func main() {
  11. rand.Seed(int64(0))
  12. p, err := plot.New()
  13. if err != nil {
  14. log.Fatal(err)
  15. }
  16. p.Title.Text = "Get Started"
  17. p.X.Label.Text = "X"
  18. p.Y.Label.Text = "Y"
  19. err = plotutil.AddLinePoints(p,
  20. "First", randomPoints(15),
  21. "Second", randomPoints(15),
  22. "Third", randomPoints(15))
  23. if err != nil {
  24. log.Fatal(err)
  25. }
  26. if err = p.Save(4*vg.Inch, 4*vg.Inch, "points.png"); err != nil {
  27. log.Fatal(err)
  28. }
  29. }
  30. func randomPoints(n int) plotter.XYs {
  31. points := make(plotter.XYs, n)
  32. for i := range points {
  33. if i == 0 {
  34. points[i].X = rand.Float64()
  35. } else {
  36. points[i].X = points[i-1].X + rand.Float64()
  37. }
  38. points[i].Y = points[i].X + 10 * rand.Float64()
  39. }
  40. return points
  41. }

程序运行输出points.png图片文件:

每日一库之31:plot(图表绘制) - 图1

plot的使用比较直观。首先,调用plot.New()创建一个“画布”,画布结构如下:

  1. // Plot is the basic type representing a plot.
  2. type Plot struct {
  3. Title struct {
  4. Text string
  5. Padding vg.Length
  6. draw.TextStyle
  7. }
  8. BackgroundColor color.Color
  9. X, Y Axis
  10. Legend Legend
  11. plotters []Plotter
  12. }

然后,通过直接给画布结构字段赋值,设置图像的属性。例如p.Title.Text = "Get Started设置图像标题内容;p.X.Label.Text = "X"p.Y.Label.Text = "Y"设置图像的 X 和 Y 轴的标签名。

再然后,使用plotutil或者其他子包的方法在画布上绘制,上面代码中调用AddLinePoints()绘制了 3 条折线。

最后保存图像,上面代码中调用p.Save()方法将图像保存到文件中。

更多图形

gonum/plot将不同层次的接口封装到特定的子包中:

  • plot:提供了布局和绘图的简单接口;
  • plotter:使用plot提供的接口实现了一组标准的绘图器,例如散点图、条形图、箱状图等。可以使用plotter提供的接口实现自己的绘图器;
  • plotutil:为绘制常见图形提供简便的方法;
  • vg:封装各种后端,并提供了一个通用矢量图形 API。

条形图

条形图通过相同宽度条形的高度或长短来表示数据的大小关系。将相同类型的数据放在一起比较能非常直观地看出不同,我们经常在比较几个库的性能时使用条形图。下面我们采用json-iter/go的 GitHub 仓库中用来比较jsonitereasyjsonstd三个 JSON 库性能的数据来绘制条形图:

  1. package main
  2. import (
  3. "log"
  4. "gonum.org/v1/plot"
  5. "gonum.org/v1/plot/plotter"
  6. "gonum.org/v1/plot/plotutil"
  7. "gonum.org/v1/plot/vg"
  8. )
  9. func main() {
  10. std := plotter.Values{35510, 1960, 99}
  11. easyjson := plotter.Values{8499, 160, 4}
  12. jsoniter := plotter.Values{5623, 160, 3}
  13. p, err := plot.New()
  14. if err != nil {
  15. log.Fatal(err)
  16. }
  17. p.Title.Text = "jsoniter vs easyjson vs std"
  18. p.Y.Label.Text = ""
  19. w := vg.Points(20)
  20. stdBar, err := plotter.NewBarChart(std, w)
  21. if err != nil {
  22. log.Fatal(err)
  23. }
  24. stdBar.LineStyle.Width = vg.Length(0)
  25. stdBar.Color = plotutil.Color(0)
  26. stdBar.Offset = -w
  27. easyjsonBar, err := plotter.NewBarChart(easyjson, w)
  28. if err != nil {
  29. log.Fatal(err)
  30. }
  31. easyjsonBar.LineStyle.Width = vg.Length(0)
  32. easyjsonBar.Color = plotutil.Color(1)
  33. jsoniterBar, err := plotter.NewBarChart(jsoniter, w)
  34. if err != nil {
  35. log.Fatal(err)
  36. }
  37. jsoniterBar.LineStyle.Width = vg.Length(0)
  38. jsoniterBar.Color = plotutil.Color(2)
  39. jsoniterBar.Offset = w
  40. p.Add(stdBar, easyjsonBar, jsoniterBar)
  41. p.Legend.Add("std", stdBar)
  42. p.Legend.Add("easyjson", easyjsonBar)
  43. p.Legend.Add("jsoniter", jsoniterBar)
  44. p.Legend.Top = true
  45. p.NominalX("ns/op", "allocation bytes", "allocation times")
  46. if err = p.Save(5*vg.Inch, 5*vg.Inch, "barchart.png"); err != nil {
  47. log.Fatal(err)
  48. }
  49. }

首先生成值列表,我们在最开始的例子中生成了二维坐标列表plotter.XYs,实际上还有三维坐标列表plotter.XYZs

然后,调用plotter.NewBarChart()分别为三组数据生成条形图。w = vg.Points(20)用来设置条形的宽度。LineStyle.Width设置线宽,这个实际上是边框的宽度。Color设置颜色。Offset设置偏移,因为每组对应位置的条形放在一起显示更好比较,将stdBar.Offset设置为-w会让其向左偏移一个条形的宽度;easyjson偏移不设置,默认为 0,不偏移;jsoniter偏移设置为w,向右偏移一个条形的宽度。最终它们紧挨着显示。

然后,将 3 个条形图添加到画布上。紧接着,设置它们的图例,并将其显示在顶部。

最后调用p.Save()保存图片。

程序运行生成下面的图片:

每日一库之31:plot(图表绘制) - 图2

可以很直观地看到jsoniter的性能、内存占用、内存分配次数各方面都是顶尖的。可能用同一种维度的数据,数量级相差不大,图像会好看点(┬_┬)。

注意plotter.Color(2)这类用法。plot预定义了一组颜色值,如果我们想要使用它们,可以直接传入索引获取对应的颜色,更多的是为了区分不同的图形(例如上面的 3 个条形图用了 3 个不同的索引):

  1. // src/gonum.org/v1/plot/plotutil/plotutil.go
  2. var DefaultColors = SoftColors
  3. var SoftColors = []color.Color{
  4. rgb(241, 90, 96),
  5. rgb(122, 195, 106),
  6. rgb(90, 155, 212),
  7. rgb(250, 167, 91),
  8. rgb(158, 103, 171),
  9. rgb(206, 112, 88),
  10. rgb(215, 127, 180),
  11. }
  12. func Color(i int) color.Color {
  13. n := len(DefaultColors)
  14. if i < 0 {
  15. return DefaultColors[i%n+n]
  16. }
  17. return DefaultColors[i%n]
  18. }

除了颜色,还有形状plotter.Shape(i)和划线模式plotter.Dashes(i)

**vg.Length(0)**有所不同,这个只是将 0 转换为**vg.Length**类型!

函数图像

plot可以绘制函数图像!

  1. func main() {
  2. p, err := plot.New()
  3. if err != nil {
  4. log.Fatal(err)
  5. }
  6. p.Title.Text = "Functions"
  7. p.X.Label.Text = "X"
  8. p.Y.Label.Text = "Y"
  9. square := plotter.NewFunction(func(x float64) float64 { return x * x })
  10. square.Color = plotutil.Color(0)
  11. sqrt := plotter.NewFunction(func(x float64) float64 { return 10 * math.Sqrt(x) })
  12. sqrt.Dashes = []vg.Length{vg.Points(1), vg.Points(2)}
  13. sqrt.Width = vg.Points(1)
  14. sqrt.Color = plotutil.Color(1)
  15. exp := plotter.NewFunction(func(x float64) float64 { return math.Pow(2, x) })
  16. exp.Dashes = []vg.Length{vg.Points(2), vg.Points(3)}
  17. exp.Width = vg.Points(2)
  18. exp.Color = plotutil.Color(2)
  19. sin := plotter.NewFunction(func(x float64) float64 { return 10*math.Sin(x) + 50 })
  20. sin.Dashes = []vg.Length{vg.Points(3), vg.Points(4)}
  21. sin.Width = vg.Points(3)
  22. sin.Color = plotutil.Color(3)
  23. p.Add(square, sqrt, exp, sin)
  24. p.Legend.Add("x^2", square)
  25. p.Legend.Add("10*sqrt(x)", sqrt)
  26. p.Legend.Add("2^x", exp)
  27. p.Legend.Add("10*sin(x)+50", sin)
  28. p.Legend.ThumbnailWidth = 0.5 * vg.Inch
  29. p.X.Min = 0
  30. p.X.Max = 10
  31. p.Y.Min = 0
  32. p.Y.Max = 100
  33. if err = p.Save(4*vg.Inch, 4*vg.Inch, "functions.png"); err != nil {
  34. log.Fatal(err)
  35. }
  36. }

首先调用plotter.NewFunction()创建一个函数图像。它接受一个函数,单输入参数float64,单输出参数float64,故只能画出单自变量的函数图像。接着为函数图像设置了三个属性Dashes(划线)、Width(线宽)和Color(颜色)。默认使用连续的线条来绘制函数,如图中的平方函数。可以通过设置Dashesplot绘制不连续的线条,Dashes接受两个长度值,第一个长度表示间隔距离,第二个长度表示连续线的长度。这里也使用到了plotutil.Color(i)依次使用前 4 个预定义的颜色。

创建画布、设置图例这些都与前面的相同。这里还通过p.Xp.YMin/Max属性限制了图像绘制的坐标范围。

运行程序生成图像:

每日一库之31:plot(图表绘制) - 图3

气泡图

使用plot可以画出非常好看的气泡图:

  1. func main() {
  2. n := 10
  3. bubbleData := randomTriples(n)
  4. minZ, maxZ := math.Inf(1), math.Inf(-1)
  5. for _, xyz := range bubbleData {
  6. if xyz.Z > maxZ {
  7. maxZ = xyz.Z
  8. }
  9. if xyz.Z < minZ {
  10. minZ = xyz.Z
  11. }
  12. }
  13. p, err := plot.New()
  14. if err != nil {
  15. log.Fatal(err)
  16. }
  17. p.Title.Text = "Bubbles"
  18. p.X.Label.Text = "X"
  19. p.Y.Label.Text = "Y"
  20. bs, err := plotter.NewScatter(bubbleData)
  21. if err != nil {
  22. log.Fatal(err)
  23. }
  24. bs.GlyphStyleFunc = func(i int) draw.GlyphStyle {
  25. c := color.RGBA{R: 196, B: 128, A: 255}
  26. var minRadius, maxRadius = vg.Points(1), vg.Points(20)
  27. rng := maxRadius - minRadius
  28. _, _, z := bubbleData.XYZ(i)
  29. d := (z - minZ) / (maxZ - minZ)
  30. r := vg.Length(d)*rng + minRadius
  31. return draw.GlyphStyle{Color: c, Radius: r, Shape: draw.CircleGlyph{}}
  32. }
  33. p.Add(bs)
  34. if err = p.Save(4*vg.Inch, 4*vg.Inch, "bubble.png"); err != nil {
  35. log.Fatal(err)
  36. }
  37. }
  38. func randomTriples(n int) plotter.XYZs {
  39. data := make(plotter.XYZs, n)
  40. for i := range data {
  41. if i == 0 {
  42. data[i].X = rand.Float64()
  43. } else {
  44. data[i].X = data[i-1].X + 2*rand.Float64()
  45. }
  46. data[i].Y = data[i].X + 10*rand.Float64()
  47. data[i].Z = data[i].X
  48. }
  49. return data
  50. }

我们生成一组三维坐标点,调用plotter.NewScatter()生成散点图。我们设置了GlyphStyleFunc钩子函数,在绘制每个点之前都会调用它,它返回一个draw.GlyphStyle类型,plot会根据返回的这个对象来绘制。我们的例子中,每次我们都返回一个表示圆形的draw.GlyphStyle对象,通过Z坐标与最大、最小坐标的比例映射到[vg.Points(1)vg.Points(20)]区间中得到半径。

生成的图像:

每日一库之31:plot(图表绘制) - 图4

同样地,我们可以返回正方形的draw.GlyphStyle的对象来绘制“方形图”,只需要把钩子函数GlyphStyleFunc的返回语句做些修改:

  1. return draw.GlyphStyle{Color: c, Radius: r, Shape: draw.SquareGlyph{}}

即可绘制“方形图”😄:

每日一库之31:plot(图表绘制) - 图5

实际应用

下面我们应用之前文章中介绍的[gopsutil](https://go-quiz.github.io/2020/04/05/godailylib/gopsutil)和本文中的plot搭建一个网页,可以实时观察机器的 CPU 和内存占用:

  1. func index(w http.ResponseWriter, r *http.Request) {
  2. t, err := template.ParseFiles("index.html")
  3. if err != nil {
  4. log.Fatal(err)
  5. }
  6. t.Execute(w, nil)
  7. }
  8. func image(w http.ResponseWriter, r *http.Request) {
  9. monitor.WriteTo(w)
  10. }
  11. func main() {
  12. mux := http.NewServeMux()
  13. mux.HandleFunc("/", index)
  14. mux.HandleFunc("/image", image)
  15. go monitor.Run()
  16. s := &http.Server{
  17. Addr: ":8080",
  18. Handler: mux,
  19. }
  20. if err := s.ListenAndServe(); err != nil {
  21. log.Fatal(err)
  22. }
  23. }

首先,我们编写了一个 HTTP 服务器,监听在 8080 端口。设置两个路由,/显示主页,/image调用Monitor的方法生成 CPU 和内存占用图返回。Monitor结构稍后会介绍。index.html的内容如下:

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>Monitor</title>
  7. </head>
  8. <body>
  9. <img src="/image" alt="" id="img">
  10. <script>
  11. let img = document.querySelector("#img")
  12. setInterval(function () {
  13. img.src = "/image?s=" + Math.random()
  14. }, 500)
  15. </script>
  16. </body>
  17. </html>

页面比较简单,就显示了一张图片。然后在 JS 中启动一个 500ms 的定时器,每隔 500ms 就重新请求一次图片替换现有的图片。我在设置img.src属性时在后面添加了一个随机数,这是为了防止缓存导致得到的可能不是最新的图片。

下面看看Monitor的结构:

  1. type Monitor struct {
  2. Mem []float64
  3. CPU []float64
  4. MaxRecord int
  5. Lock sync.Mutex
  6. }
  7. func NewMonitor(max int) *Monitor {
  8. return &Monitor{
  9. MaxRecord: max,
  10. }
  11. }
  12. var monitor = NewMonitor(50)

这个结构中记录了最近的 50 条记录。每隔 500ms 会收集一次 CPU 和内存的占用情况,记录到CPUMem字段中:

  1. func (m *Monitor) Collect() {
  2. mem, err := mem.VirtualMemory()
  3. if err != nil {
  4. log.Fatal(err)
  5. }
  6. cpu, err := cpu.Percent(500*time.Millisecond, false)
  7. if err != nil {
  8. log.Fatal(err)
  9. }
  10. m.Lock.Lock()
  11. defer m.Lock.Unlock()
  12. m.Mem = append(m.Mem, mem.UsedPercent)
  13. m.CPU = append(m.CPU, cpu[0])
  14. }
  15. func (m *Monitor) Run() {
  16. for {
  17. m.Collect()
  18. time.Sleep(500 * time.Millisecond)
  19. }
  20. }

当 HTTP 请求/image路由时,根据目前已经收集到的CPUMem数据生成图片返回:

  1. func (m *Monitor) WriteTo(w io.Writer) {
  2. m.Lock.Lock()
  3. defer m.Lock.Unlock()
  4. cpuData := make(plotter.XYs, len(m.CPU))
  5. for i, p := range m.CPU {
  6. cpuData[i].X = float64(i + 1)
  7. cpuData[i].Y = p
  8. }
  9. memData := make(plotter.XYs, len(m.Mem))
  10. for i, p := range m.Mem {
  11. memData[i].X = float64(i + 1)
  12. memData[i].Y = p
  13. }
  14. p, err := plot.New()
  15. if err != nil {
  16. log.Fatal(err)
  17. }
  18. cpuLine, err := plotter.NewLine(cpuData)
  19. if err != nil {
  20. log.Fatal(err)
  21. }
  22. cpuLine.Color = plotutil.Color(1)
  23. memLine, err := plotter.NewLine(memData)
  24. if err != nil {
  25. log.Fatal(err)
  26. }
  27. memLine.Color = plotutil.Color(2)
  28. p.Add(cpuLine, memLine)
  29. p.Legend.Add("cpu", cpuLine)
  30. p.Legend.Add("mem", memLine)
  31. p.X.Min = 0
  32. p.X.Max = float64(m.MaxRecord)
  33. p.Y.Min = 0
  34. p.Y.Max = 100
  35. wc, err := p.WriterTo(4*vg.Inch, 4*vg.Inch, "png")
  36. if err != nil {
  37. log.Fatal(err)
  38. }
  39. wc.WriteTo(w)
  40. }

运行服务器:

  1. $ go run main.go

打开浏览器,输入localhost:8080,观察图片变化:

每日一库之31:plot(图表绘制) - 图6

总结

本文介绍了强大的绘图库plot,最后通过一个监控程序结尾。限于篇幅,plot提供的多种绘图类型未能一一介绍。plot还支持svg/pdf等多种格式的保存。感兴趣的童鞋可自行研究。

大家如果发现好玩、好用的 Go 语言库,欢迎到 Go 每日一库 GitHub 上提交 issue😄

参考

  1. plot GitHub:https://github.com/gonum/plot
  2. Example Plots: https://github.com/gonum/plot/wiki/Example-plots
  3. Go 每日一库 GitHub:https://github.com/go-quiz/go-daily-lib