termdash/widgets/sparkline/sparkline.go

254 lines
6.5 KiB
Go

// Copyright 2018 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package sparkline is a widget that draws a graph showing a series of values as vertical bars.
package sparkline
import (
"errors"
"fmt"
"image"
"sync"
"github.com/mum4k/termdash/cell"
"github.com/mum4k/termdash/private/area"
"github.com/mum4k/termdash/private/canvas"
"github.com/mum4k/termdash/private/draw"
"github.com/mum4k/termdash/terminal/terminalapi"
"github.com/mum4k/termdash/widgetapi"
)
// SparkLine draws a graph showing a series of values as vertical bars.
//
// Bars can have sub-cell height. The graphs scale adjusts dynamically based on
// the largest visible value.
//
// Implements widgetapi.Widget. This object is thread-safe.
type SparkLine struct {
// data are the data points the SparkLine displays.
data []int
// lastWidth is the width of the canvas as of the last time when Draw was called.
lastWidth int
// mu protects the SparkLine.
mu sync.Mutex
// opts are the provided options.
opts *options
}
// New returns a new SparkLine.
func New(opts ...Option) (*SparkLine, error) {
opt := newOptions()
for _, o := range opts {
o.set(opt)
}
if err := opt.validate(); err != nil {
return nil, err
}
return &SparkLine{
opts: opt,
}, nil
}
// Draw draws the SparkLine widget onto the canvas.
// Implements widgetapi.Widget.Draw.
func (sl *SparkLine) Draw(cvs *canvas.Canvas, meta *widgetapi.Meta) error {
sl.mu.Lock()
defer sl.mu.Unlock()
sl.lastWidth = cvs.Area().Dx()
needAr, err := area.FromSize(sl.minSize())
if err != nil {
return err
}
if !needAr.In(cvs.Area()) {
return draw.ResizeNeeded(cvs)
}
ar := sl.area(cvs)
visible, max := visibleMax(sl.data, ar.Dx())
var curX int
if len(visible) < ar.Dx() {
curX = ar.Max.X - len(visible)
} else {
curX = ar.Min.X
}
for _, v := range visible {
blocks := toBlocks(v, max, ar.Dy())
curY := ar.Max.Y - 1
for i := 0; i < blocks.full; i++ {
if _, err := cvs.SetCell(
image.Point{curX, curY},
sparks[len(sparks)-1], // Last spark represents full cell.
cell.FgColor(sl.opts.color),
); err != nil {
return err
}
curY--
}
if blocks.partSpark != 0 {
if _, err := cvs.SetCell(
image.Point{curX, curY},
blocks.partSpark,
cell.FgColor(sl.opts.color),
); err != nil {
return err
}
}
curX++
}
if sl.opts.label != "" {
// Label is placed immediately above the SparkLine.
lStart := image.Point{ar.Min.X, ar.Min.Y - 1}
if err := draw.Text(cvs, sl.opts.label, lStart,
draw.TextCellOpts(sl.opts.labelCellOpts...),
draw.TextOverrunMode(draw.OverrunModeThreeDot),
); err != nil {
return err
}
}
return nil
}
// ValueCapacity returns the number of values that can fit into the canvas.
// This is essentially the number of available cells on the canvas as observed
// on the last call to draw. Returns zero if draw wasn't called.
//
// Note that this capacity changes each time the terminal resizes, so there is
// no guarantee this remains the same next time Draw is called.
// Should be used as a hint only.
func (sl *SparkLine) ValueCapacity() int {
sl.mu.Lock()
defer sl.mu.Unlock()
return sl.lastWidth
}
// Add adds data points to the SparkLine.
// Each data point is represented by one bar on the SparkLine. Zero value data
// points are valid and are represented by an empty space on the SparkLine
// (i.e. a missing bar).
//
// At least one data point must be provided. All data points must be positive
// integers.
//
// The last added data point will be the one displayed all the way on the right
// of the SparkLine. If there are more data points than we can fit bars to the
// width of the SparkLine, only the last n data points that fit will be
// visible.
//
// Provided options override values set when New() was called.
func (sl *SparkLine) Add(data []int, opts ...Option) error {
sl.mu.Lock()
defer sl.mu.Unlock()
for _, opt := range opts {
opt.set(sl.opts)
}
for i, d := range data {
if d < 0 {
return fmt.Errorf("data point[%d]: %v must be a positive integer", i, d)
}
}
sl.data = append(sl.data, data...)
return nil
}
// Clear removes all the data points in the SparkLine, effectively returning to
// an empty graph.
func (sl *SparkLine) Clear() {
sl.mu.Lock()
defer sl.mu.Unlock()
sl.data = nil
}
// Keyboard input isn't supported on the SparkLine widget.
func (*SparkLine) Keyboard(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) error {
return errors.New("the SparkLine widget doesn't support keyboard events")
}
// Mouse input isn't supported on the SparkLine widget.
func (*SparkLine) Mouse(m *terminalapi.Mouse, meta *widgetapi.EventMeta) error {
return errors.New("the SparkLine widget doesn't support mouse events")
}
// area returns the area of the canvas available to the SparkLine.
func (sl *SparkLine) area(cvs *canvas.Canvas) image.Rectangle {
cvsAr := cvs.Area()
maxY := cvsAr.Max.Y
// Height is determined based on options (fixed height / label).
var minY int
if sl.opts.height > 0 {
minY = maxY - sl.opts.height
} else {
minY = cvsAr.Min.Y
if sl.opts.label != "" {
minY++ // Reserve one line for the label.
}
}
return image.Rect(
cvsAr.Min.X,
minY,
cvsAr.Max.X,
maxY,
)
}
// minSize returns the minimum canvas size for the SparkLine based on the options.
func (sl *SparkLine) minSize() image.Point {
const minWidth = 1 // At least one data point.
var minHeight int
if sl.opts.height > 0 {
minHeight = sl.opts.height
} else {
minHeight = 1 // At least one line of characters.
}
if sl.opts.label != "" {
minHeight++ // One line for the text label.
}
return image.Point{minWidth, minHeight}
}
// Options implements widgetapi.Widget.Options.
func (sl *SparkLine) Options() widgetapi.Options {
sl.mu.Lock()
defer sl.mu.Unlock()
min := sl.minSize()
var max image.Point
if sl.opts.height > 0 {
max = min // Fix the height to the one specified.
}
return widgetapi.Options{
MinimumSize: min,
MaximumSize: max,
WantKeyboard: widgetapi.KeyScopeNone,
WantMouse: widgetapi.MouseScopeNone,
}
}