blob: 5c877308435e028a816e2cfe5542fa7852d089ca [file] [log] [blame]
// Licensed to Apache Software Foundation (ASF) under one or more contributor
// license agreements. See the NOTICE file distributed with
// this work for additional information regarding copyright
// ownership. Apache Software Foundation (ASF) licenses this file to you 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 heatmap
import (
"errors"
"fmt"
"image"
"math"
"sort"
"sync"
"github.com/apache/skywalking-cli/pkg/heatmap/axes"
"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"
)
// columnValues represent values stored in a column.
type columnValues struct {
// values are the values in a column.
values []int64
// Min is the smallest value in the column, zero if values is empty.
Min int64
// Max is the largest value in the column, zero if values is empty.
Max int64
}
// newColumnValues returns a new columnValues instance.
func newColumnValues(values []int64) *columnValues {
// Copy to avoid external modifications.
v := make([]int64, len(values))
copy(v, values)
min, max := minMax(values)
return &columnValues{
values: v,
Min: min,
Max: max,
}
}
// HeatMap draws heatmap charts.
// Implements widgetapi.Widget. This object is thread-safe.
type HeatMap struct {
columns map[string]*columnValues
// XLabels are the labels on the X axis in an increasing order.
XLabels []string
// YLabels are the labels on the Y axis in an increasing order.
YLabels []string
// MinValue and MaxValue are the Min and Max values in the columns.
MinValue, MaxValue int64
// opts are the provided options.
opts *options
// mu protects the HeatMap widget.
mu sync.RWMutex
}
// NewHeatMap returns a new HeatMap widget.
func NewHeatMap(opts ...Option) (*HeatMap, error) {
opt := newOptions(opts...)
if err := opt.validate(); err != nil {
return nil, err
}
return &HeatMap{
columns: map[string]*columnValues{},
opts: opt,
}, nil
}
// SetColumns sets the HeatMap's values, min and max values.
func (hp *HeatMap) SetColumns(values map[string][]int64) {
hp.mu.Lock()
defer hp.mu.Unlock()
var minMaxValues []int64
// The iteration order of map is uncertain, so the keys must be sorted explicitly.
var names []string
for name := range values {
names = append(names, name)
}
sort.Strings(names)
// Clear XLabels and columns.
if len(hp.XLabels) > 0 {
hp.XLabels = hp.XLabels[:0]
}
hp.columns = make(map[string]*columnValues)
for _, name := range names {
cv := newColumnValues(values[name])
hp.columns[name] = cv
hp.XLabels = append(hp.XLabels, name)
minMaxValues = append(minMaxValues, cv.Min)
minMaxValues = append(minMaxValues, cv.Max)
}
hp.MinValue, hp.MaxValue = minMax(minMaxValues)
}
// SetYLabels sets HeatMap's Y-Labels.
func (hp *HeatMap) SetYLabels(labels []string) {
hp.mu.Lock()
defer hp.mu.Unlock()
// Clear YLabels.
if len(hp.YLabels) > 0 {
hp.YLabels = hp.YLabels[:0]
}
hp.YLabels = append(hp.YLabels, labels...)
// Reverse the array.
for i, j := 0, len(hp.YLabels)-1; i < j; i, j = i+1, j-1 {
hp.YLabels[i], hp.YLabels[j] = hp.YLabels[j], hp.YLabels[i]
}
}
// axesDetails determines the details about the X and Y axes.
func (hp *HeatMap) axesDetails(cvs *canvas.Canvas) (*axes.XDetails, *axes.YDetails, error) {
yd, err := axes.NewYDetails(hp.YLabels)
if err != nil {
return nil, nil, err
}
xd, err := axes.NewXDetails(cvs.Area(), yd.End, hp.XLabels, hp.opts.cellWidth)
if err != nil {
return nil, nil, err
}
return xd, yd, nil
}
// Draw draws the values as HeatMap.
// Implements widgetapi.Widget.Draw.
func (hp *HeatMap) Draw(cvs *canvas.Canvas, meta *widgetapi.Meta) error {
hp.mu.Lock()
defer hp.mu.Unlock()
// Check if the canvas has enough area to draw HeatMap.
needAr, err := area.FromSize(hp.minSize())
if err != nil {
return err
}
if !needAr.In(cvs.Area()) {
return draw.ResizeNeeded(cvs)
}
xd, yd, err := hp.axesDetails(cvs)
if err != nil {
return err
}
err = hp.drawColumns(cvs, xd, yd)
if err != nil {
return err
}
return hp.drawAxes(cvs, xd, yd)
}
// drawColumns draws the graph representing the stored series.
// Returns XDetails that might be adjusted to not start at zero value if some
// of the series didn't fit the graphs and XAxisUnscaled was provided.
// If the series has NaN values they will be ignored and not draw on the graph.
func (hp *HeatMap) drawColumns(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.YDetails) error {
for i, xl := range hp.XLabels {
cv := hp.columns[xl]
for j := 0; j < len(cv.values); j++ {
v := cv.values[j]
startX := xd.Start.X + 1 + i*hp.opts.cellWidth
startY := yd.Labels[j].Pos.Y
endX := startX + hp.opts.cellWidth
endY := startY + 1
rect := image.Rect(startX, startY, endX, endY)
color := hp.getBlockColor(v)
if err := cvs.SetAreaCells(rect, ' ', cell.BgColor(color)); err != nil {
return err
}
}
}
return nil
}
// drawAxes draws the X,Y axes and their labels.
func (hp *HeatMap) drawAxes(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.YDetails) error {
for _, l := range yd.Labels {
if err := draw.Text(cvs, l.Text, l.Pos,
draw.TextMaxX(yd.Start.X),
draw.TextOverrunMode(draw.OverrunModeThreeDot),
draw.TextCellOpts(hp.opts.yLabelCellOpts...),
); err != nil {
return fmt.Errorf("failed to draw the Y labels: %v", err)
}
}
for _, l := range xd.Labels {
if err := draw.Text(cvs, l.Text, l.Pos, draw.TextCellOpts(hp.opts.xLabelCellOpts...)); err != nil {
return fmt.Errorf("failed to draw the X horizontal labels: %v", err)
}
}
return nil
}
// minSize determines the minimum required size to draw HeatMap.
func (hp *HeatMap) minSize() image.Point {
// At the very least we need:
// - n cells width for the Y axis and its labels.
// - m cells width for the graph.
reqWidth := axes.LongestString(hp.YLabels) + axes.AxisWidth + hp.opts.cellWidth*len(hp.columns)
// For the height:
// - 1 cells height for labels on the X axis.
// - n cell height for the graph.
reqHeight := 1 + len(hp.YLabels)
return image.Point{X: reqWidth, Y: reqHeight}
}
// Keyboard input isn't supported on the SparkLine widget.
func (*HeatMap) Keyboard(k *terminalapi.Keyboard) error {
return errors.New("the HeatMap widget doesn't support keyboard events")
}
// Mouse input isn't supported on the SparkLine widget.
func (*HeatMap) Mouse(m *terminalapi.Mouse) error {
return errors.New("the HeatMap widget doesn't support mouse events")
}
// Options implements widgetapi.Widget.Options.
func (hp *HeatMap) Options() widgetapi.Options {
hp.mu.Lock()
defer hp.mu.Unlock()
return widgetapi.Options{}
}
// getBlockColor returns the color of the block according to the value.
// The larger the value, the darker the color.
func (hp *HeatMap) getBlockColor(value int64) cell.Color {
const colorNum = 23
scale := float64(hp.MaxValue - hp.MinValue)
fv := float64(value)
// Refer to https://jonasjacek.github.io/colors/.
// The color range is in Xterm color [232, 255].
rgb := int(255 - (fv / scale * colorNum))
return cell.ColorNumber(rgb)
}
// minMax returns the min and max values in given integer array.
func minMax(values []int64) (min, max int64) {
min = math.MaxInt64
max = math.MinInt64
for _, v := range values {
min = int64(math.Min(float64(min), float64(v)))
max = int64(math.Max(float64(max), float64(v)))
}
return
}