sampler-fork/widgets/runchart.go

419 lines
9.9 KiB
Go
Raw Normal View History

2019-01-28 23:09:52 +00:00
package widgets
import (
"fmt"
2019-02-02 14:45:53 +00:00
"github.com/sqshq/sampler/console"
"github.com/sqshq/sampler/data"
2019-01-28 23:09:52 +00:00
"image"
"math"
2019-01-31 00:02:38 +00:00
"strconv"
2019-01-28 23:09:52 +00:00
"sync"
"time"
ui "github.com/sqshq/termui"
2019-01-28 23:09:52 +00:00
)
// TODO split into runchart, grid, legend files
2019-01-28 23:09:52 +00:00
const (
2019-02-04 04:03:59 +00:00
xAxisLegendWidth = 20
xAxisLabelsHeight = 1
xAxisLabelsWidth = 8
xAxisLabelsGap = 2
xAxisGridWidth = xAxisLabelsGap + xAxisLabelsWidth
yAxisLabelsHeight = 1
yAxisLabelsGap = 1
chartHistoryReserve = 5
2019-01-28 23:09:52 +00:00
)
2019-01-31 01:41:51 +00:00
type RunChart struct {
ui.Block
lines []timeLine
grid chartGrid
precision int
2019-02-04 04:03:59 +00:00
timescale time.Duration
mutex *sync.Mutex
2019-01-31 01:41:51 +00:00
}
type chartGrid struct {
valueExtrema valueExtrema
timeRange timeRange
linesCount int
paddingWidth int
maxTimeWidth int
minTimeWidth int
2019-01-28 23:09:52 +00:00
}
type timePoint struct {
value float64
time time.Time
timeCoordinate int
2019-01-28 23:09:52 +00:00
}
type timeLine struct {
points []timePoint
color ui.Color
label string
2019-01-31 01:41:51 +00:00
}
type timeRange struct {
2019-01-31 01:41:51 +00:00
max time.Time
min time.Time
2019-01-28 23:09:52 +00:00
}
type valueExtrema struct {
2019-01-31 01:41:51 +00:00
max float64
min float64
}
2019-01-28 23:09:52 +00:00
2019-02-04 04:03:59 +00:00
func NewRunChart(title string, precision int, refreshRateMs int) *RunChart {
block := *ui.NewBlock()
block.Title = title
return &RunChart{
Block: block,
lines: []timeLine{},
mutex: &sync.Mutex{},
2019-02-04 04:03:59 +00:00
precision: precision,
timescale: calculateTimescale(refreshRateMs),
}
}
func (self *RunChart) newChartGrid() chartGrid {
2019-01-31 01:41:51 +00:00
2019-02-04 04:03:59 +00:00
linesCount := (self.Inner.Max.X - self.Inner.Min.X - self.grid.minTimeWidth) / xAxisGridWidth
timeRange := getTimeRange(linesCount, self.timescale)
return chartGrid{
timeRange: timeRange,
valueExtrema: getValueExtrema(self.lines, timeRange),
linesCount: linesCount,
paddingWidth: xAxisGridWidth,
maxTimeWidth: self.Inner.Max.X,
minTimeWidth: self.getMaxValueLength(),
}
}
2019-01-31 01:41:51 +00:00
func (self *RunChart) newTimePoint(value float64) timePoint {
now := time.Now()
return timePoint{
value: value,
time: now,
timeCoordinate: self.calculateTimeCoordinate(now),
2019-01-28 23:09:52 +00:00
}
}
func (self *RunChart) Draw(buffer *ui.Buffer) {
2019-01-28 23:09:52 +00:00
self.mutex.Lock()
2019-02-03 03:30:45 +00:00
self.Block.Draw(buffer)
2019-01-31 01:41:51 +00:00
self.grid = self.newChartGrid()
2019-01-28 23:09:52 +00:00
2019-01-29 14:34:15 +00:00
drawArea := image.Rect(
self.Inner.Min.X+self.grid.minTimeWidth+1, self.Inner.Min.Y,
2019-01-29 14:34:15 +00:00
self.Inner.Max.X, self.Inner.Max.Y-xAxisLabelsHeight-1,
)
2019-01-28 23:09:52 +00:00
2019-02-03 03:30:45 +00:00
self.renderAxes(buffer)
self.renderLines(buffer, drawArea)
self.renderLegend(buffer, drawArea)
self.mutex.Unlock()
2019-01-28 23:09:52 +00:00
}
2019-02-03 03:30:45 +00:00
func (self *RunChart) ConsumeSample(sample data.Sample) {
float, err := strconv.ParseFloat(sample.Value, 64)
2019-01-31 00:02:38 +00:00
if err != nil {
// TODO visual notification + check sample.Error
2019-01-31 00:02:38 +00:00
}
self.mutex.Lock()
2019-02-03 03:30:45 +00:00
lineIndex := -1
2019-01-31 23:40:05 +00:00
for i, line := range self.lines {
2019-02-03 03:30:45 +00:00
if line.label == sample.Label {
lineIndex = i
}
}
2019-02-03 03:30:45 +00:00
if lineIndex == -1 {
line := &timeLine{
points: []timePoint{},
2019-02-03 03:30:45 +00:00
color: sample.Color,
label: sample.Label,
}
2019-02-03 03:30:45 +00:00
self.lines = append(self.lines, *line)
lineIndex = len(self.lines) - 1
}
2019-02-03 03:30:45 +00:00
line := self.lines[lineIndex]
timePoint := self.newTimePoint(float)
2019-02-03 03:30:45 +00:00
line.points = append(line.points, timePoint)
self.lines[lineIndex] = line
2019-01-28 23:09:52 +00:00
self.trimOutOfRangeValues()
self.mutex.Unlock()
2019-01-28 23:09:52 +00:00
}
func (self *RunChart) renderLines(buffer *ui.Buffer, drawArea image.Rectangle) {
canvas := ui.NewCanvas()
canvas.Rectangle = drawArea
2019-01-28 23:09:52 +00:00
if len(self.lines) == 0 || len(self.lines[0].points) == 0 {
return
2019-01-28 23:09:52 +00:00
}
probe := self.lines[0].points[0]
delta := ui.AbsInt(self.calculateTimeCoordinate(probe.time) - probe.timeCoordinate)
2019-01-28 23:09:52 +00:00
2019-01-31 23:40:05 +00:00
for _, line := range self.lines {
2019-01-28 23:09:52 +00:00
xToPoint := make(map[int]image.Point)
pointsOrder := make([]int, 0)
2019-01-28 23:09:52 +00:00
for i, timePoint := range line.points {
2019-01-28 23:09:52 +00:00
timePoint.timeCoordinate = timePoint.timeCoordinate - delta
line.points[i] = timePoint
2019-01-28 23:09:52 +00:00
var y int
if self.grid.valueExtrema.max == self.grid.valueExtrema.min {
y = (drawArea.Dy() - 2) / 2
} else {
valuePerY := (self.grid.valueExtrema.max - self.grid.valueExtrema.min) / float64(drawArea.Dy()-2)
y = int(float64(timePoint.value-self.grid.valueExtrema.min) / valuePerY)
}
2019-01-28 23:09:52 +00:00
point := image.Pt(timePoint.timeCoordinate, drawArea.Max.Y-y-1)
2019-01-28 23:09:52 +00:00
if _, exists := xToPoint[point.X]; exists {
continue
}
2019-01-28 23:09:52 +00:00
if !point.In(drawArea) {
continue
}
xToPoint[point.X] = point
pointsOrder = append(pointsOrder, point.X)
}
2019-01-28 23:09:52 +00:00
for i, x := range pointsOrder {
2019-01-28 23:09:52 +00:00
currentPoint := xToPoint[x]
var previousPoint image.Point
2019-01-28 23:09:52 +00:00
if i == 0 {
previousPoint = currentPoint
} else {
previousPoint = xToPoint[pointsOrder[i-1]]
}
2019-01-28 23:09:52 +00:00
canvas.Line(
braillePoint(previousPoint),
braillePoint(currentPoint),
2019-02-03 03:30:45 +00:00
line.color,
)
}
2019-01-28 23:09:52 +00:00
}
2019-02-01 05:07:25 +00:00
canvas.Draw(buffer)
2019-01-28 23:09:52 +00:00
}
func (self *RunChart) renderAxes(buffer *ui.Buffer) {
// draw origin cell
buffer.SetCell(
ui.NewCell(ui.BOTTOM_LEFT, ui.NewStyle(ui.ColorWhite)),
image.Pt(self.Inner.Min.X+self.grid.minTimeWidth, self.Inner.Max.Y-xAxisLabelsHeight-1),
)
// draw x axis line
for i := self.grid.minTimeWidth + 1; i < self.Inner.Dx(); i++ {
buffer.SetCell(
ui.NewCell(ui.HORIZONTAL_DASH, ui.NewStyle(ui.ColorWhite)),
image.Pt(i+self.Inner.Min.X, self.Inner.Max.Y-xAxisLabelsHeight-1),
)
}
// draw grid lines
for y := 0; y < self.Inner.Dy()-xAxisLabelsHeight-2; y = y + 2 {
for x := 1; x <= self.grid.linesCount; x++ {
buffer.SetCell(
ui.NewCell(ui.VERTICAL_DASH, ui.NewStyle(console.ColorDarkGrey)),
2019-02-04 04:03:59 +00:00
image.Pt(self.grid.maxTimeWidth-x*xAxisGridWidth, y+self.Inner.Min.Y+1),
)
}
}
// draw y axis line
for i := 0; i < self.Inner.Dy()-xAxisLabelsHeight-1; i++ {
buffer.SetCell(
ui.NewCell(ui.VERTICAL_DASH, ui.NewStyle(ui.ColorWhite)),
image.Pt(self.Inner.Min.X+self.grid.minTimeWidth, i+self.Inner.Min.Y),
)
}
// draw x axis time labels
for i := 1; i <= self.grid.linesCount; i++ {
labelTime := self.grid.timeRange.max.Add(time.Duration(-i) * self.timescale)
buffer.SetString(
labelTime.Format("15:04:05"),
ui.NewStyle(ui.ColorWhite),
2019-02-04 04:03:59 +00:00
image.Pt(self.grid.maxTimeWidth-xAxisLabelsWidth/2-i*(xAxisGridWidth), self.Inner.Max.Y-1),
)
}
// draw y axis labels
if self.grid.valueExtrema.max != self.grid.valueExtrema.min {
labelsCount := (self.Inner.Dy() - xAxisLabelsHeight - 1) / (yAxisLabelsGap + yAxisLabelsHeight)
valuePerY := (self.grid.valueExtrema.max - self.grid.valueExtrema.min) / float64(self.Inner.Dy()-xAxisLabelsHeight-3)
for i := 0; i < int(labelsCount); i++ {
value := self.grid.valueExtrema.max - (valuePerY * float64(i) * (yAxisLabelsGap + yAxisLabelsHeight))
buffer.SetString(
formatValue(value, self.precision),
ui.NewStyle(ui.ColorWhite),
image.Pt(self.Inner.Min.X, 1+self.Inner.Min.Y+i*(yAxisLabelsGap+yAxisLabelsHeight)),
)
}
} else {
buffer.SetString(
formatValue(self.grid.valueExtrema.max, self.precision),
ui.NewStyle(ui.ColorWhite),
image.Pt(self.Inner.Min.X, self.Inner.Dy()/2))
}
}
func (self *RunChart) renderLegend(buffer *ui.Buffer, rectangle image.Rectangle) {
2019-01-28 23:09:52 +00:00
2019-02-03 03:30:45 +00:00
for i, line := range self.lines {
2019-01-28 23:09:52 +00:00
extremum := getLineValueExtremum(line.points)
2019-02-01 05:07:25 +00:00
buffer.SetString(
string(ui.DOT),
ui.NewStyle(line.color),
2019-02-01 05:07:25 +00:00
image.Pt(self.Inner.Max.X-xAxisLegendWidth-2, self.Inner.Min.Y+1+i*5),
2019-01-28 23:09:52 +00:00
)
2019-02-01 05:07:25 +00:00
buffer.SetString(
2019-02-03 03:30:45 +00:00
fmt.Sprintf("%s", line.label),
ui.NewStyle(line.color),
2019-02-01 05:07:25 +00:00
image.Pt(self.Inner.Max.X-xAxisLegendWidth, self.Inner.Min.Y+1+i*5),
2019-01-28 23:09:52 +00:00
)
buffer.SetString(
fmt.Sprintf("cur %s", formatValue(line.points[len(line.points)-1].value, self.precision)),
ui.NewStyle(ui.ColorWhite),
image.Pt(self.Inner.Max.X-xAxisLegendWidth, self.Inner.Min.Y+2+i*5),
)
buffer.SetString(
fmt.Sprintf("max %s", formatValue(extremum.max, self.precision)),
ui.NewStyle(ui.ColorWhite),
image.Pt(self.Inner.Max.X-xAxisLegendWidth, self.Inner.Min.Y+3+i*5),
)
buffer.SetString(
fmt.Sprintf("min %s", formatValue(extremum.min, self.precision)),
ui.NewStyle(ui.ColorWhite),
image.Pt(self.Inner.Max.X-xAxisLegendWidth, self.Inner.Min.Y+4+i*5),
)
2019-01-28 23:09:52 +00:00
}
}
func (self *RunChart) trimOutOfRangeValues() {
// TODO use hard limit
}
func (self *RunChart) calculateTimeCoordinate(t time.Time) int {
timeDeltaWithGridMaxTime := self.grid.timeRange.max.Sub(t).Nanoseconds()
timeDeltaToPaddingRelation := float64(timeDeltaWithGridMaxTime) / float64(self.timescale.Nanoseconds())
return self.grid.maxTimeWidth - (int(float64(xAxisGridWidth) * timeDeltaToPaddingRelation))
}
func (self *RunChart) getMaxValueLength() int {
maxValueLength := 0
for _, line := range self.lines {
for _, point := range line.points {
2019-02-03 03:30:45 +00:00
l := len(formatValue(point.value, self.precision))
if l > maxValueLength {
maxValueLength = l
}
}
}
return maxValueLength
}
func formatValue(value float64, precision int) string {
2019-02-03 03:30:45 +00:00
format := "%." + strconv.Itoa(precision) + "f"
return fmt.Sprintf(format, value)
2019-02-01 05:07:25 +00:00
}
func getValueExtrema(items []timeLine, timeRange timeRange) valueExtrema {
2019-01-28 23:09:52 +00:00
if len(items) == 0 {
return valueExtrema{0, 0}
2019-01-28 23:09:52 +00:00
}
var max, min = -math.MaxFloat64, math.MaxFloat64
2019-01-28 23:09:52 +00:00
for _, item := range items {
2019-01-31 23:40:05 +00:00
for _, point := range item.points {
if point.value > max && timeRange.isInRange(point.time) {
2019-02-03 03:30:45 +00:00
max = point.value
}
if point.value < min && timeRange.isInRange(point.time) {
2019-02-03 03:30:45 +00:00
min = point.value
}
2019-01-28 23:09:52 +00:00
}
}
return valueExtrema{max: max, min: min}
}
func (r *timeRange) isInRange(time time.Time) bool {
return time.After(r.min) && time.Before(r.max)
2019-01-31 01:41:51 +00:00
}
func getLineValueExtremum(points []timePoint) valueExtrema {
2019-02-01 05:07:25 +00:00
if len(points) == 0 {
return valueExtrema{0, 0}
2019-02-01 05:07:25 +00:00
}
var max, min = -math.MaxFloat64, math.MaxFloat64
for _, point := range points {
2019-02-03 03:30:45 +00:00
if point.value > max {
max = point.value
2019-02-01 05:07:25 +00:00
}
2019-02-03 03:30:45 +00:00
if point.value < min {
min = point.value
2019-02-01 05:07:25 +00:00
}
}
return valueExtrema{max: max, min: min}
2019-02-01 05:07:25 +00:00
}
func getTimeRange(linesCount int, scale time.Duration) timeRange {
2019-01-31 01:41:51 +00:00
maxTime := time.Now()
return timeRange{
2019-01-31 01:41:51 +00:00
max: maxTime,
2019-02-04 04:03:59 +00:00
min: maxTime.Add(-time.Duration(scale.Nanoseconds() * int64(linesCount))),
}
}
func calculateTimescale(refreshRateMs int) time.Duration {
multiplier := refreshRateMs * xAxisGridWidth / 2
timescale := time.Duration(time.Millisecond * time.Duration(multiplier)).Round(time.Second)
if timescale.Seconds() == 0 {
return time.Second
} else {
return timescale
2019-01-31 01:41:51 +00:00
}
2019-01-28 23:09:52 +00:00
}