| // 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 |
| } |