sampler-fork/component/runchart/runchart.go

364 lines
8.1 KiB
Go
Raw Normal View History

2019-02-15 04:34:45 +00:00
package runchart
import (
2019-03-11 03:43:47 +00:00
"github.com/sqshq/sampler/component"
2019-03-26 03:29:23 +00:00
"github.com/sqshq/sampler/component/util"
2019-03-08 04:04:46 +00:00
"github.com/sqshq/sampler/config"
2019-02-15 04:34:45 +00:00
"github.com/sqshq/sampler/console"
"github.com/sqshq/sampler/data"
"image"
"math"
"sync"
"time"
2019-03-14 03:01:44 +00:00
ui "github.com/gizak/termui/v3"
2019-02-15 04:34:45 +00:00
)
const (
xAxisGridWidth = xAxisLabelsIndent + xAxisLabelsWidth
xAxisLabelsHeight = 1
xAxisLabelsWidth = 8
xAxisLabelsIndent = 2
yAxisLabelsHeight = 1
yAxisLabelsIndent = 1
historyReserveMin = 2
2019-02-15 04:34:45 +00:00
xBrailleMultiplier = 2
yBrailleMultiplier = 4
)
type Mode int
const (
ModeDefault Mode = 0
ModePinpoint Mode = 1
2019-02-15 04:34:45 +00:00
)
const (
CommandDisableSelection = "DISABLE_SELECTION"
CommandMoveSelection = "MOVE_SELECTION"
)
2019-07-27 04:15:35 +00:00
// RunChart displays observed data in a time sequence
2019-02-15 04:34:45 +00:00
type RunChart struct {
2019-03-16 23:59:28 +00:00
*ui.Block
*data.Consumer
2019-02-15 04:34:45 +00:00
lines []TimeLine
2019-07-27 04:15:35 +00:00
grid chartGrid
2019-02-15 04:34:45 +00:00
timescale time.Duration
mutex *sync.Mutex
mode Mode
selection time.Time
2019-02-19 04:07:32 +00:00
scale int
2019-07-27 04:15:35 +00:00
legend legend
palette console.Palette
2019-02-15 04:34:45 +00:00
}
type TimePoint struct {
value float64
time time.Time
coordinate int
}
type TimeLine struct {
2019-02-16 02:52:03 +00:00
points []TimePoint
extrema ValueExtrema
color ui.Color
label string
selectionCoordinate int
selectionPoint TimePoint
2019-02-15 04:34:45 +00:00
}
type TimeRange struct {
max time.Time
min time.Time
}
type ValueExtrema struct {
max float64
min float64
}
func NewRunChart(c config.RunChartConfig, palette console.Palette) *RunChart {
2019-03-08 04:04:46 +00:00
chart := RunChart{
Block: component.NewBlock(c.Title, true, palette),
2019-03-16 23:59:28 +00:00
Consumer: data.NewConsumer(),
2019-02-15 04:34:45 +00:00
lines: []TimeLine{},
2019-04-07 15:17:28 +00:00
timescale: calculateTimescale(*c.RateMs),
2019-02-15 04:34:45 +00:00
mutex: &sync.Mutex{},
2019-03-08 04:04:46 +00:00
scale: *c.Scale,
mode: ModeDefault,
2019-07-27 04:15:35 +00:00
legend: legend{Enabled: c.Legend.Enabled, Details: c.Legend.Details},
palette: palette,
2019-03-08 04:04:46 +00:00
}
for _, i := range c.Items {
chart.AddLine(*i.Label, *i.Color)
}
2019-03-08 04:04:46 +00:00
go func() {
for {
select {
case sample := <-chart.SampleChannel:
chart.consumeSample(sample)
2019-03-16 23:59:28 +00:00
case alert := <-chart.AlertChannel:
chart.Alert = alert
case command := <-chart.CommandChannel:
switch command.Type {
case CommandDisableSelection:
chart.disableSelection()
case CommandMoveSelection:
chart.moveSelection(command.Value.(int))
}
}
2019-03-08 04:04:46 +00:00
}
}()
return &chart
2019-02-15 04:34:45 +00:00
}
func (c *RunChart) newTimePoint(value float64) TimePoint {
now := time.Now()
return TimePoint{
value: value,
time: now,
coordinate: c.calculateTimeCoordinate(now),
}
}
func (c *RunChart) Draw(buffer *ui.Buffer) {
c.mutex.Lock()
c.Block.Draw(buffer)
c.grid = c.newChartGrid()
drawArea := image.Rect(
c.Inner.Min.X+c.grid.minTimeWidth+2, c.Inner.Min.Y,
2019-02-15 04:34:45 +00:00
c.Inner.Max.X, c.Inner.Max.Y-xAxisLabelsHeight-1,
)
c.renderAxes(buffer)
2019-02-15 04:34:45 +00:00
c.renderLines(buffer, drawArea)
c.renderLegend(buffer, drawArea)
component.RenderAlert(c.Alert, c.Rectangle, buffer)
2019-02-15 04:34:45 +00:00
c.mutex.Unlock()
}
func (c *RunChart) AddLine(Label string, color ui.Color) {
line := TimeLine{
points: []TimePoint{},
color: color,
label: Label,
extrema: ValueExtrema{max: -math.MaxFloat64, min: math.MaxFloat64},
}
c.lines = append(c.lines, line)
}
2019-03-16 23:59:28 +00:00
func (c *RunChart) consumeSample(sample *data.Sample) {
2019-02-15 04:34:45 +00:00
2019-04-07 18:26:20 +00:00
float, err := util.ParseFloat(sample.Value)
2019-02-15 04:34:45 +00:00
if err != nil {
c.HandleConsumeFailure("Failed to parse a number", err, sample)
2019-03-16 23:59:28 +00:00
return
2019-02-15 04:34:45 +00:00
}
2019-07-27 04:15:35 +00:00
c.HandleConsumeSuccess()
2019-02-15 04:34:45 +00:00
c.mutex.Lock()
index := -1
2019-02-15 04:34:45 +00:00
for i, line := range c.lines {
if line.label == sample.Label {
index = i
2019-02-15 04:34:45 +00:00
}
}
line := c.lines[index]
2019-02-16 02:52:03 +00:00
if float < line.extrema.min {
line.extrema.min = float
}
if float > line.extrema.max {
line.extrema.max = float
}
line.points = append(line.points, c.newTimePoint(float))
c.lines[index] = line
if len(line.points)%100 == 0 {
c.trimOutOfRangeValues()
}
2019-02-15 04:34:45 +00:00
c.mutex.Unlock()
}
func (c *RunChart) renderLines(buffer *ui.Buffer, drawArea image.Rectangle) {
canvas := ui.NewCanvas()
canvas.Rectangle = drawArea
if len(c.lines) == 0 || len(c.lines[0].points) == 0 {
return
}
selectionCoordinate := c.calculateTimeCoordinate(c.selection)
selectionPoints := make(map[int]image.Point)
probe := c.lines[0].points[0]
probeCalculatedCoordinate := c.calculateTimeCoordinate(probe.time)
delta := probe.coordinate - probeCalculatedCoordinate
2019-02-15 04:34:45 +00:00
for i, line := range c.lines {
xPoint := make(map[int]image.Point)
xOrder := make([]int, 0)
2019-02-16 02:52:03 +00:00
// move selection on a delta, if it was instantiated after cursor move
if line.selectionCoordinate != 0 {
line.selectionCoordinate -= delta
c.lines[i].selectionCoordinate = line.selectionCoordinate
2019-02-15 04:34:45 +00:00
}
for j, timePoint := range line.points {
timePoint.coordinate -= delta
line.points[j] = timePoint
var y int
if c.grid.valueExtrema.max == c.grid.valueExtrema.min {
y = (drawArea.Dy() - 2) / 2
} else {
valuePerY := (c.grid.valueExtrema.max - c.grid.valueExtrema.min) / float64(drawArea.Dy()-2)
y = int(float64(timePoint.value-c.grid.valueExtrema.min) / valuePerY)
}
point := image.Pt(timePoint.coordinate, drawArea.Max.Y-y-1)
if _, exists := xPoint[point.X]; exists {
continue
}
if !point.In(drawArea) {
continue
}
2019-02-16 02:52:03 +00:00
if line.selectionCoordinate == 0 {
// instantiate selection coordinate as the closest point to the cursor time
2019-02-15 04:34:45 +00:00
if len(line.points) > j+1 && ui.AbsInt(timePoint.coordinate-selectionCoordinate) > ui.AbsInt(line.points[j+1].coordinate-selectionCoordinate) {
selectionPoints[i] = point
2019-02-16 02:52:03 +00:00
c.lines[i].selectionPoint = timePoint
2019-02-15 04:34:45 +00:00
}
2019-02-16 02:52:03 +00:00
} else if timePoint.coordinate == line.selectionCoordinate {
selectionPoints[i] = point
2019-02-15 04:34:45 +00:00
}
xPoint[point.X] = point
xOrder = append(xOrder, point.X)
}
for i, x := range xOrder {
currentPoint := xPoint[x]
var previousPoint image.Point
if i == 0 {
previousPoint = currentPoint
} else {
previousPoint = xPoint[xOrder[i-1]]
}
2019-03-14 03:01:44 +00:00
canvas.SetLine(
2019-02-15 04:34:45 +00:00
braillePoint(previousPoint),
braillePoint(currentPoint),
line.color,
)
}
}
canvas.Draw(buffer)
if c.mode == ModePinpoint {
2019-02-15 04:34:45 +00:00
for lineIndex, point := range selectionPoints {
buffer.SetCell(ui.NewCell(console.SymbolSelection, ui.NewStyle(c.lines[lineIndex].color)), point)
2019-02-16 02:52:03 +00:00
if c.lines[lineIndex].selectionCoordinate == 0 {
c.lines[lineIndex].selectionCoordinate = point.X
2019-02-15 04:34:45 +00:00
}
}
}
}
func (c *RunChart) trimOutOfRangeValues() {
minRangeTime := c.grid.timeRange.min.Add(-time.Minute * time.Duration(historyReserveMin))
for i, item := range c.lines {
lastOutOfRangeValueIndex := -1
for j, point := range item.points {
if point.time.Before(minRangeTime) {
lastOutOfRangeValueIndex = j
}
}
if lastOutOfRangeValueIndex > 0 {
item.points = append(item.points[:0], item.points[lastOutOfRangeValueIndex+1:]...)
c.lines[i] = item
}
}
}
func (c *RunChart) calculateTimeCoordinate(t time.Time) int {
timeDeltaWithGridMaxTime := c.grid.timeRange.max.Sub(t).Nanoseconds()
timeDeltaToPaddingRelation := float64(timeDeltaWithGridMaxTime) / float64(c.timescale.Nanoseconds())
return c.grid.maxTimeWidth - int(math.Ceil(float64(xAxisGridWidth)*timeDeltaToPaddingRelation))
}
func (c *RunChart) moveSelection(shift int) {
2019-02-15 04:34:45 +00:00
if c.mode == ModeDefault {
c.mode = ModePinpoint
2019-02-15 04:34:45 +00:00
c.selection = getMidRangeTime(c.grid.timeRange)
return
2019-07-27 04:15:35 +00:00
}
c.selection = c.selection.Add(c.grid.timePerPoint * time.Duration(shift))
if c.selection.After(c.grid.timeRange.max) {
c.selection = c.grid.timeRange.max
} else if c.selection.Before(c.grid.timeRange.min) {
c.selection = c.grid.timeRange.min
2019-02-15 04:34:45 +00:00
}
for i := range c.lines {
2019-02-16 02:52:03 +00:00
c.lines[i].selectionCoordinate = 0
2019-02-15 04:34:45 +00:00
}
}
func (c *RunChart) disableSelection() {
if c.mode == ModePinpoint {
c.mode = ModeDefault
2019-02-15 04:34:45 +00:00
return
}
}
func getMidRangeTime(r TimeRange) time.Time {
delta := r.max.Sub(r.min)
return r.max.Add(-delta / 2)
}
// time duration between grid lines
2019-04-07 15:17:28 +00:00
func calculateTimescale(rateMs int) time.Duration {
2019-02-15 04:34:45 +00:00
2019-04-07 15:17:28 +00:00
multiplier := rateMs * xAxisGridWidth / 2
2019-02-15 04:34:45 +00:00
timescale := time.Duration(time.Millisecond * time.Duration(multiplier)).Round(time.Second)
if timescale.Seconds() == 0 {
return time.Second
}
2019-07-27 04:15:35 +00:00
return timescale
2019-02-15 04:34:45 +00:00
}
func braillePoint(point image.Point) image.Point {
return image.Point{X: point.X * xBrailleMultiplier, Y: point.Y * yBrailleMultiplier}
}