Skip to content
Overlays, zoom and drill-down

Overlays, zoom and drill-down

These features sit on top of any chart’s series. Overlays mark values on the plot. Zoom, the toolbox, the brush and drill-down let the user explore the data. A timeline steps through a sequence of charts. Each section has a compiled example. The overview covers tooltips, the legend and the event bus, and Trading charts covers multi-pane groups and drawing tools.

Overlays

An overlay marks a value on the plot, in data coordinates, so it moves with the data when the user zooms. There are three kinds:

  • a ReferenceLine at one value, such as a target or a limit;
  • a ReferenceBand shading the range between From and To;
  • an Annotation, a text label above an X, Y point, with an optional arrow to it.
A line chart of API latency over 48 hours with a dashed red SLO line at 250 ms, a blue Normal band from 80 to 160, an orange Maintenance band, a red vertical Deploy line, and a Cache miss storm label with an arrow pointing at a spike A line chart of API latency over 48 hours with a dashed red SLO line at 250 ms, a blue Normal band from 80 to 160, an orange Maintenance band, a red vertical Deploy line, and a Cache miss storm label with an arrow pointing at a spike
red := color.NRGBA{R: 229, G: 57, B: 53, A: 220}
overlays := []chart.Overlay{
	// Horizontal: constant Y, across the whole plot.
	&chart.ReferenceLine{
		Axis: chart.AxisHorizontal, Value: 250, Label: "SLO 250 ms",
		Color: red, Dashed: true, DashPattern: []float32{8, 4},
	},
	&chart.ReferenceBand{Axis: chart.AxisHorizontal, From: 80, To: 160, Label: "Normal"},
	// Vertical: constant X. On a category axis X is the category index.
	&chart.ReferenceBand{
		Axis: chart.AxisVertical, From: 19, To: 22, Label: "Maintenance",
		Color: color.NRGBA{R: 255, G: 167, B: 38, A: 50},
	},
	&chart.ReferenceLine{Axis: chart.AxisVertical, Value: 12, Label: "Deploy", Width: 2},
	// An annotation sits at an X, Y data point.
	&chart.Annotation{X: 31, Y: 290, Text: "Cache miss storm", Arrow: true},
}

Pass the list as Option.Overlays. How they behave:

  • Axis chooses the direction. AxisHorizontal marks a Y value across the whole width; AxisVertical marks an X value across the whole height. On a category axis, X is the category index.
  • YAxisIndex picks the Y axis a horizontal line, band or annotation is measured against, when the chart has several.
  • Width is the line width (default 1.5). Dashed dashes the line, 6 pixels on and 4 off unless DashPattern says otherwise.
  • With no Color, lines and bands use the theme’s overlay colours, and annotations the title colour.
  • A line’s label sits at the right end of a horizontal line, or at the top of a vertical one. A band’s label sits in its top-left corner.
  • An annotation whose point is outside the plot, for example after zooming, is not drawn.

Overlays need axes: on a chart with only pie, gauge or other self-laid-out series, they are not drawn.

Zoom

Set DataZoom with Enabled: true and the user can zoom along the X axis:

  • the wheel zooms in and out around the pointer;
  • once zoomed in, a drag pans;
  • the +, − and Reset buttons in the top-right corner zoom around the middle, or show all the data again.
A line chart zoomed to days 201 to 310 of a year, with the zoom buttons at the top right and a slider below whose highlighted window covers the same part of the year A line chart zoomed to days 201 to 310 of a year, with the zoom buttons at the top right and a slider below whose highlighted window covers the same part of the year
c := chart.New(chart.Option{
	Title: &chart.Title{Text: "Daily visits"},
	// Enabled turns on the wheel, the drag-to-pan and the + − Reset buttons.
	// MinSpan and MaxSpan bound the visible fraction of the data.
	DataZoom: &interact.ZoomConfig{Enabled: true, MinSpan: 0.05, MaxSpan: 1, ZoomSensitivity: 0.1},
	Tooltip:  &component.TooltipConfig{Trigger: component.TooltipTriggerAxis},
	Grid:     &chart.Grid{Left: 56, Right: 20, Top: 12, Bottom: 30},
	XAxis:    []axis.Config{{Type: axis.TypeCategory, Data: days}},
	YAxis:    []axis.Config{{Type: axis.TypeValue}},
	Series:   []series.Series{&series.LineSeries{SeriesName: "Visits", Data: visits}},
})

ZoomConfig holds the limits. MinSpan and MaxSpan are the smallest and largest part of the data the user can show, as fractions from 0 to 1. ZoomSensitivity scales each wheel step.

The visible part is a DataWindow: Start and End, also fractions of the full range. Read it with ZoomWindow(), set it with SetZoomWindow, and listen for EventZoomChange to hear about changes the user makes.

A zoom slider

component.DataZoomSlider is a separate widget: a bar with a highlighted window that the user drags by its body or by either edge. It is not connected to a chart, so you connect it both ways:

slider := component.NewDataZoomSlider(func(win interact.DataWindow) {
	c.SetZoomWindow(win) // does not emit EventZoomChange, so no loop
	show(win)
})
c.Events.On(interact.EventZoomChange, func(evt interface{}) {
	e := evt.(interact.ZoomChangeEvent)
	slider.SetWindow(interact.DataWindow{Start: e.Start, End: e.End})
	show(interact.DataWindow{Start: e.Start, End: e.End})
})

SetZoomWindow does not send EventZoomChange, so the two handlers cannot call each other forever. The same goes for buttons that jump to a fixed range:

lastQuarter := widget.NewButton("Last 90 days", func() {
	win := interact.DataWindow{Start: 1 - 90.0/365, End: 1}
	c.SetZoomWindow(win)
	slider.SetWindow(win)
	show(win)
})
all := widget.NewButton("All", func() {
	c.SetZoomWindow(interact.FullWindow())
	slider.SetWindow(interact.FullWindow())
	show(interact.FullWindow())
})

Some limits of zoom in v1.0.0:

  • Only the X axis zooms. The Y axis keeps the range of all the data, as the screenshot shows: the visible values stay between 1000 and 1400 while the axis runs from 800 to 2000.
  • Bars and candlesticks keep the width they have at full range, so they look thin when zoomed in far.
  • SetZoomWindow does nothing on a chart without DataZoom.
  • The slider has fixed dark colours that do not follow the theme.

DataZoom with Enabled: false still lets SetZoomWindow move the window, but the user cannot zoom that chart and it has no buttons. That is how a pane follows the others in a chart group.

Toolbox

The toolbox adds small buttons at the top right of the chart, under the zoom buttons if there are any.

A bar chart of signups per weekday with line and bar buttons at its top right, and a Save PNG button above the chart A bar chart of signups per weekday with line and bar buttons at its top right, and a Save PNG button above the chart
c := chart.New(chart.Option{
	Title: &chart.Title{Text: "Signups", SubText: "Switch between line and bar"},
	Toolbox: &component.ToolboxConfig{
		MagicType: []string{"line", "bar"}, // one button per type
	},
	Grid:  &chart.Grid{Left: 56, Right: 20, Top: 12, Bottom: 30},
	XAxis: []axis.Config{{Type: axis.TypeCategory, Data: []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}}},
	YAxis: []axis.Config{{Type: axis.TypeValue}},
	Series: []series.Series{
		&series.BarSeries{SeriesName: "Signups", Data: []float64{120, 200, 150, 80, 70, 110, 130}},
	},
})

MagicType adds one button per chart type. "line" turns every bar series into a line series, and "bar" turns every line series into bars. Name, data, Y axis and stack are kept; other settings of the series, such as colours, dashes and area fills, are not. Button labels are cut to four letters.

SaveAsImage: true adds a Save button that asks for a file name and writes a PNG. In v1.0.0 that PNG holds only the chart’s background colour. Until that is fixed, cut the chart out of a capture of the window instead:

// chartImage returns the pixels of c as they are on screen, cut out of a
// capture of the whole window.
func chartImage(w bellui.Window, c *chart.Chart) image.Image {
	cv := w.Canvas()
	pos := bellui.CurrentApp().Driver().AbsolutePositionForObject(c)
	x0, y0 := cv.PixelCoordinateForPosition(pos)
	x1, y1 := cv.PixelCoordinateForPosition(pos.Add(c.Size()))
	img := cv.Capture()
	if sub, ok := img.(interface {
		SubImage(image.Rectangle) image.Image
	}); ok {
		return sub.SubImage(image.Rect(x0, y0, x1, y1))
	}
	return img
}
save := widget.NewButton("Save PNG", func() {
	img := chartImage(w, c)
	dialog.ShowFileSave(func(out bellui.URIWriteCloser, err error) {
		if err != nil || out == nil {
			return // cancelled
		}
		defer out.Close()
		if err := png.Encode(out, img); err != nil {
			dialog.ShowError(err, w)
		}
	}, w)
})

Capture returns what is on screen, at the screen’s pixel density, so the image is as sharp as the window.

Brush selection

With Brush enabled, a drag over the plot draws a selection rectangle instead of panning. When the user lets go, the chart sends EventBrushSelect with the points inside the rectangle.

A scatter chart of height against weight with a blue selection rectangle over part of the points, and a label above reading 56 people selected, mean weight 75.5 kg A scatter chart of height against weight with a blue selection rectangle over part of the points, and a label above reading 56 people selected, mean weight 75.5 kg
c := chart.New(chart.Option{
	Title:  &chart.Title{Text: "Height and weight", SubText: "Drag to select"},
	Brush:  &interact.BrushConfig{Enabled: true},
	Grid:   &chart.Grid{Left: 56, Right: 20, Top: 12, Bottom: 30},
	XAxis:  []axis.Config{{Type: axis.TypeValue}},
	YAxis:  []axis.Config{{Type: axis.TypeValue}},
	Series: []series.Series{&series.ScatterSeries{SeriesName: "People", Data: people, SymbolSize: 7}},
})

result := widget.NewLabel("Drag a rectangle over the points.")
c.Events.On(interact.EventBrushSelect, func(evt interface{}) {
	e := evt.(interact.BrushSelectEvent)
	if len(e.Selected) == 0 {
		result.SetText("Nothing selected")
		return
	}
	sum := 0.0
	for _, p := range e.Selected {
		sum += p.Value.Y // p.SeriesIndex and p.DataIndex locate the point
	}
	result.SetText(fmt.Sprintf("%d people selected, mean weight %.1f kg",
		len(e.Selected), sum/float64(len(e.Selected))))
})

Each BrushSelectedPoint has the SeriesIndex and DataIndex of the point and its Value. The rectangle disappears when the drag ends, and the chart does not mark the selected points: to highlight them, change the option yourself.

In v1.0.0 the brush selects points of line and scatter series, but not bars or candlesticks. BrushSelectEvent also declares DataMinX, DataMaxX, DataMinY and DataMaxY, but the chart leaves them at zero.

Drill-down

Drill-down replaces the chart with a more detailed one when the user clicks a data point, and shows a breadcrumb trail to go back.

A bar chart of Europe's revenue by quarter, reached by clicking the Europe slice of a pie chart, with a breadcrumb reading Root > Europe and a back button under the title A bar chart of Europe's revenue by quarter, reached by clicking the Europe slice of a pie chart, with a breadcrumb reading Root > Europe and a back button under the title

Set Drilldown.DataProvider to a function that returns the child option for a clicked point, or nil when that point has nothing below it:

// region returns the child chart for one pie slice, or nil to not drill.
func region(hit interact.HitResult, path []string) *chart.Option {
	if len(path) > 0 {
		return nil // already one level down: the bars do not drill further
	}
	name := hit.Value.Label // a pie hit carries the slice name
	data, ok := quarterly[name]
	if !ok {
		return nil
	}
	return &chart.Option{
		Title:  &chart.Title{Text: name, SubText: "Revenue by quarter"},
		Grid:   &chart.Grid{Left: 56, Right: 20, Top: 12, Bottom: 30},
		XAxis:  []axis.Config{{Type: axis.TypeCategory, Data: []string{"Q1", "Q2", "Q3", "Q4"}}},
		YAxis:  []axis.Config{{Type: axis.TypeValue}},
		Series: []series.Series{&series.BarSeries{SeriesName: name, Data: data, CornerRadius: 4}},
	}
}
slices := []series.PieDataItem{}
for _, name := range []string{"Europe", "Americas", "Asia"} {
	total := 0.0
	for _, v := range quarterly[name] {
		total += v
	}
	slices = append(slices, series.PieDataItem{Name: name, Value: total})
}
c := chart.New(chart.Option{
	Title:     &chart.Title{Text: "Revenue by region", SubText: "Click a slice"},
	Series:    []series.Series{&series.PieSeries{SeriesName: "Revenue", Data: slices, ShowLabels: true}},
	Drilldown: &chart.DrilldownConfig{DataProvider: region},
})

where := widget.NewLabel("Top level")
c.Events.OnFiltered(interact.EventFilter{
	EventTypes: []interact.EventType{interact.EventDrilldown, interact.EventDrillUp},
}, func(evt interface{}) {
	e := evt.(interact.DrilldownEvent)
	where.SetText("Path: " + strings.Join(e.Path, " > "))
})

The function receives the clicked HitResult and the current path. It runs for every click on a data point, at every level: a child option without its own Drilldown keeps the parent’s. Use the path, as the example does, to stop at the last level.

The breadcrumb appears under the title once the user is below the top level:

  • < goes up one level;
  • Root goes back to the top;
  • every level between them can be clicked; the last one, where the user is, cannot.

Each level’s name in the trail is the clicked point’s label. Pie slices have one, their Name. For other series the label is empty and the series name is used instead, so give the series a name that makes sense in the trail.

From code, DrillInto(label, option) goes one level down, DrillUp() one level up, and DrillPath() returns the names of the levels. Going down or up resets the legend and the zoom.

In v1.0.0, EventDrilldown and EventDrillUp fire for clicks on data points, on < and for calls to DrillInto and DrillUp, but not for clicks on Root or another level in the trail. DrilldownEvent.Hit is always nil.

Timeline

A timeline steps through a sequence of charts, one per year for example. component.TimelineState keeps the current step and plays through the steps on a timer. It has no user interface of its own and is not attached to a chart: it calls your function with each step’s index, and your function sets the chart.

tl := component.NewTimelineState(len(years),
	component.TimelineConfig{Interval: 1500 * time.Millisecond, Loop: true},
	func(i int) {
		// Play calls this from its own goroutine: hop to the UI thread.
		bellui.Do(func() {
			c.SetOption(optionFor(i))
			year.SetText(fmt.Sprintf("%s (%d of %d)", years[i], i+1, len(years)))
		})
	})

controls := container.NewHBox(
	widget.NewButton("Previous", func() { tl.Stop(); tl.Prev() }),
	widget.NewButton("Play", tl.Play),
	widget.NewButton("Stop", tl.Stop),
	widget.NewButton("Next", func() { tl.Stop(); tl.Next() }),
	year,
)

How it behaves:

  • Next, Prev and GoTo change the step and call your function. They do not go past the first or the last step, except that with Loop, Next goes from the last step back to the first.
  • Play advances one step every Interval (default 2 seconds). Without Loop it stops at the last step; with Loop it starts again at the first. Stop ends playback.
  • During playback your function runs on a timer goroutine, not the UI thread, so wrap chart and widget updates in bellui.Do.
  • TimelineState has no lock. Stop playback before stepping by hand, as the example does.
  • TimelineConfig.AutoPlay has no effect in v1.0.0: call Play yourself.

With Animation: true in each option, SetOption animates from one step to the next.

Colours by value

Option.VisualMap takes a list of component.VisualMapConfig, which describe a colour scale from Min to Max. In v1.0.0 the chart does not use them: setting Option.VisualMap, or calling VisualMap on the builder, changes nothing on screen.

The config’s own methods do work. MapColor returns the colour for a value, blending between the colours in InRange.Colors, and MapSize returns a size between InRange.SymbolSizeMin and SymbolSizeMax. Values outside Min and Max get the colour or size at the nearest end. Use them where a series takes a colour per item, such as pie slices:

A pie chart of errors by service whose slices run from pale orange for few errors to deep orange for the most, billing A pie chart of errors by service whose slices run from pale orange for few errors to deep orange for the most, billing
vm := component.VisualMapConfig{
	Min: 0, Max: 40,
	InRange: component.VisualMapRange{Colors: []color.Color{
		color.NRGBA{R: 255, G: 224, B: 178, A: 255}, // low
		color.NRGBA{R: 230, G: 81, B: 0, A: 255},    // high
	}},
}

errorsByService := map[string]float64{"auth": 4, "search": 12, "billing": 38, "media": 21, "mail": 8}
var slices []series.PieDataItem
for _, name := range []string{"auth", "search", "billing", "media", "mail"} {
	v := errorsByService[name]
	slices = append(slices, series.PieDataItem{Name: name, Value: v, Color: vm.MapColor(v)})
}

A heatmap has a colour scale of its own, ColorStops; see Heatmap.