diff --git a/config.yml b/config.yml index acb09ff..e0f20a9 100644 --- a/config.yml +++ b/config.yml @@ -8,7 +8,7 @@ runcharts: script: curl -o /dev/null -s -w '%{time_total}' https://search.yahoo.com/ - label: BING script: curl -o /dev/null -s -w '%{time_total}' https://www.bing.com/ - refresh-rate-ms: 200 + refresh-rate-ms: 500 decimal-places: 3 alert: value: diff --git a/config/config.go b/config/config.go index 5a2254e..10e4594 100644 --- a/config/config.go +++ b/config/config.go @@ -1,7 +1,6 @@ package config import ( - "fmt" "github.com/sqshq/sampler/console" "github.com/sqshq/sampler/data" . "github.com/sqshq/sampler/widgets" @@ -17,22 +16,28 @@ type Config struct { } type RunChartConfig struct { - Title string `yaml:"title"` - Items []data.Item `yaml:"items"` - Position Position `yaml:"position"` - Size Size `yaml:"size"` - RefreshRateMs int `yaml:"refresh-rate-ms"` - Precision int `yaml:"decimal-places"` + Title string `yaml:"title"` + Items []data.Item `yaml:"items"` + Position Position `yaml:"position"` + Size Size `yaml:"size"` + RefreshRateMs int `yaml:"refresh-rate-ms"` + Precision int `yaml:"decimal-places"` + Legend LegendConfig `yaml:"legend"` } -func Load(args []string) *Config { +type LegendConfig struct { + Enabled bool `yaml:"enabled"` + Details bool `yaml:"details"` +} - if len(args) < 2 { - fmt.Fprintf(os.Stderr, "Please specify config file location. See www.github.com/sqshq/sampler for the reference\n") +func Load() *Config { + + if len(os.Args) < 2 { + println("Please specify config file location. See www.github.com/sqshq/sampler for the reference") os.Exit(0) } - cfg := readFile(args[1]) + cfg := readFile(os.Args[1]) cfg.validate() cfg.setDefaultValues() cfg.setDefaultColors() diff --git a/main.go b/main.go index c7ca66a..1c45065 100644 --- a/main.go +++ b/main.go @@ -6,14 +6,14 @@ import ( "github.com/sqshq/sampler/data" "github.com/sqshq/sampler/event" "github.com/sqshq/sampler/widgets" + "github.com/sqshq/sampler/widgets/runchart" ui "github.com/sqshq/termui" - "os" "time" ) func main() { - cfg := config.Load(os.Args) + cfg := config.Load() csl := console.Console{} csl.Init() defer csl.Close() @@ -23,7 +23,8 @@ func main() { for _, c := range cfg.RunCharts { - chart := widgets.NewRunChart(c.Title, c.Precision, c.RefreshRateMs) + legend := runchart.Legend{Enabled: c.Legend.Enabled, Details: c.Legend.Details} + chart := runchart.NewRunChart(c.Title, c.Precision, c.RefreshRateMs, legend) layout.AddComponent(chart, c.Title, c.Position, c.Size, widgets.TypeRunChart) for _, item := range c.Items { diff --git a/widgets/geometry.go b/widgets/geometry.go deleted file mode 100644 index 9a1c439..0000000 --- a/widgets/geometry.go +++ /dev/null @@ -1,18 +0,0 @@ -package widgets - -import ( - "image" -) - -const ( - xBrailleMultiplier = 2 - yBrailleMultiplier = 4 -) - -func braillePoint(point image.Point) image.Point { - return image.Point{X: point.X * xBrailleMultiplier, Y: point.Y * yBrailleMultiplier} -} - -func debraillePoint(point image.Point) image.Point { - return image.Point{X: point.X / xBrailleMultiplier, Y: point.Y / yBrailleMultiplier} -} diff --git a/widgets/layout.go b/widgets/layout.go index 069d3d4..7f7729d 100644 --- a/widgets/layout.go +++ b/widgets/layout.go @@ -2,6 +2,7 @@ package widgets import ( "github.com/sqshq/sampler/console" + "github.com/sqshq/sampler/widgets/runchart" ui "github.com/sqshq/termui" ) @@ -79,7 +80,7 @@ func (l *Layout) HandleConsoleEvent(e string) { case MenuOptionPinpoint: l.mode = ModeChartPinpoint l.menu.idle() - chart := l.getSelectedComponent().Drawable.(*RunChart) + chart := l.getSelectedComponent().Drawable.(*runchart.RunChart) chart.MoveSelection(0) case MenuOptionResume: l.mode = ModeDefault @@ -94,7 +95,7 @@ func (l *Layout) HandleConsoleEvent(e string) { case console.KeyEsc: switch l.mode { case ModeChartPinpoint: - chart := l.getSelectedComponent().Drawable.(*RunChart) + chart := l.getSelectedComponent().Drawable.(*runchart.RunChart) chart.DisableSelection() fallthrough case ModeComponentSelect: @@ -110,7 +111,7 @@ func (l *Layout) HandleConsoleEvent(e string) { l.selection = 0 l.menu.highlight(l.getComponent(l.selection)) case ModeChartPinpoint: - chart := l.getSelectedComponent().Drawable.(*RunChart) + chart := l.getSelectedComponent().Drawable.(*runchart.RunChart) chart.MoveSelection(-1) case ModeComponentSelect: if l.selection > 0 { @@ -129,7 +130,7 @@ func (l *Layout) HandleConsoleEvent(e string) { l.selection = 0 l.menu.highlight(l.getComponent(l.selection)) case ModeChartPinpoint: - chart := l.getSelectedComponent().Drawable.(*RunChart) + chart := l.getSelectedComponent().Drawable.(*runchart.RunChart) chart.MoveSelection(1) case ModeComponentSelect: if l.selection < len(l.components)-1 { diff --git a/widgets/runchart.go b/widgets/runchart.go deleted file mode 100644 index 576f47c..0000000 --- a/widgets/runchart.go +++ /dev/null @@ -1,511 +0,0 @@ -package widgets - -import ( - "fmt" - "github.com/sqshq/sampler/console" - "github.com/sqshq/sampler/data" - "image" - "math" - "strconv" - "sync" - "time" - - ui "github.com/sqshq/termui" -) - -// TODO split into runchart, grid, legend files -const ( - xAxisLegendWidth = 20 - xAxisLabelsHeight = 1 - xAxisLabelsWidth = 8 - xAxisLabelsGap = 2 - xAxisGridWidth = xAxisLabelsGap + xAxisLabelsWidth - yAxisLabelsHeight = 1 - yAxisLabelsGap = 1 - - historyReserveHrs = 1 -) - -type ScrollMode int - -const ( - Auto ScrollMode = 0 - Manual ScrollMode = 1 -) - -type RunChart struct { - ui.Block - lines []TimeLine - grid ChartGrid - timescale time.Duration - mutex *sync.Mutex - scrollMode ScrollMode - selection time.Time - precision int -} - -type ChartGrid struct { - timeRange TimeRange - timePerPoint time.Duration - valueExtrema ValueExtrema - linesCount int - maxTimeWidth int - minTimeWidth int -} - -type TimePoint struct { - value float64 - time time.Time - coordinate int -} - -type TimeLine struct { - points []TimePoint - color ui.Color - label string - selection int -} - -type TimeRange struct { - max time.Time - min time.Time -} - -type ValueExtrema struct { - max float64 - min float64 -} - -func NewRunChart(title string, precision int, refreshRateMs int) *RunChart { - block := *ui.NewBlock() - block.Title = title - return &RunChart{ - Block: block, - lines: []TimeLine{}, - timescale: calculateTimescale(refreshRateMs), - mutex: &sync.Mutex{}, - precision: precision, - scrollMode: Auto, - } -} - -func (self *RunChart) newChartGrid() ChartGrid { - - linesCount := (self.Inner.Max.X - self.Inner.Min.X - self.grid.minTimeWidth) / xAxisGridWidth - timeRange := self.getTimeRange(linesCount) - - return ChartGrid{ - timeRange: timeRange, - timePerPoint: self.timescale / time.Duration(xAxisGridWidth), - valueExtrema: getValueExtrema(self.lines, timeRange), - linesCount: linesCount, - maxTimeWidth: self.Inner.Max.X, - minTimeWidth: self.getMaxValueLength(), - } -} - -func (self *RunChart) newTimePoint(value float64) TimePoint { - now := time.Now() - return TimePoint{ - value: value, - time: now, - coordinate: self.calculateTimeCoordinate(now), - } -} - -func (self *RunChart) Draw(buffer *ui.Buffer) { - - self.mutex.Lock() - self.Block.Draw(buffer) - self.grid = self.newChartGrid() - - drawArea := image.Rect( - self.Inner.Min.X+self.grid.minTimeWidth+1, self.Inner.Min.Y, - self.Inner.Max.X, self.Inner.Max.Y-xAxisLabelsHeight-1, - ) - - self.renderAxes(buffer) - self.renderLines(buffer, drawArea) - self.renderLegend(buffer, drawArea) - self.mutex.Unlock() -} - -func (self *RunChart) ConsumeSample(sample data.Sample) { - - float, err := strconv.ParseFloat(sample.Value, 64) - - if err != nil { - // TODO visual notification + check sample.Error - } - - self.mutex.Lock() - - lineIndex := -1 - - for i, line := range self.lines { - if line.label == sample.Label { - lineIndex = i - } - } - - if lineIndex == -1 { - line := &TimeLine{ - points: []TimePoint{}, - color: sample.Color, - label: sample.Label, - } - self.lines = append(self.lines, *line) - lineIndex = len(self.lines) - 1 - } - - line := self.lines[lineIndex] - timePoint := self.newTimePoint(float) - line.points = append(line.points, timePoint) - self.lines[lineIndex] = line - - self.trimOutOfRangeValues() - self.mutex.Unlock() -} - -func (self *RunChart) renderLines(buffer *ui.Buffer, drawArea image.Rectangle) { - - canvas := ui.NewCanvas() - canvas.Rectangle = drawArea - - if len(self.lines) == 0 || len(self.lines[0].points) == 0 { - return - } - - selectionCoordinate := self.calculateTimeCoordinate(self.selection) - selectionPoints := make(map[int]image.Point) - - probe := self.lines[0].points[0] - delta := ui.AbsInt(self.calculateTimeCoordinate(probe.time) - probe.coordinate) - - for i, line := range self.lines { - - xPoint := make(map[int]image.Point) - xOrder := make([]int, 0) - - if line.selection != 0 { - line.selection -= delta - self.lines[i].selection = line.selection - } - - for j, timePoint := range line.points { - - timePoint.coordinate -= delta - line.points[j] = timePoint - - 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) - } - - point := image.Pt(timePoint.coordinate, drawArea.Max.Y-y-1) - - if _, exists := xPoint[point.X]; exists { - continue - } - - if !point.In(drawArea) { - continue - } - - if line.selection == 0 { - if len(line.points) > j+1 && ui.AbsInt(timePoint.coordinate-selectionCoordinate) > ui.AbsInt(line.points[j+1].coordinate-selectionCoordinate) { - selectionPoints[i] = point - } - } else { - if timePoint.coordinate == line.selection { - selectionPoints[i] = point - } - } - - 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]] - } - - canvas.Line( - braillePoint(previousPoint), - braillePoint(currentPoint), - line.color, - ) - } - } - - canvas.Draw(buffer) - - if self.scrollMode == Manual { - for lineIndex, point := range selectionPoints { - buffer.SetCell(ui.NewCell(console.SymbolSelection, ui.NewStyle(self.lines[lineIndex].color)), point) - if self.lines[lineIndex].selection == 0 { - self.lines[lineIndex].selection = point.X - } - } - } -} - -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)), - 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), - 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.Min.Y+self.Inner.Dy()/2)) - } -} - -func (self *RunChart) renderLegend(buffer *ui.Buffer, rectangle image.Rectangle) { - - for i, line := range self.lines { - - extremum := getLineValueExtremum(line.points) - - buffer.SetString( - string(ui.DOT), - ui.NewStyle(line.color), - image.Pt(self.Inner.Max.X-xAxisLegendWidth-2, self.Inner.Min.Y+1+i*5), - ) - buffer.SetString( - fmt.Sprintf("%s", line.label), - ui.NewStyle(line.color), - image.Pt(self.Inner.Max.X-xAxisLegendWidth, self.Inner.Min.Y+1+i*5), - ) - 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), - ) - } -} - -func (self *RunChart) trimOutOfRangeValues() { - - minRangeTime := self.grid.timeRange.min.Add(-time.Hour * time.Duration(historyReserveHrs)) - - for i, item := range self.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:]...) - self.lines[i] = item - } - } -} - -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(math.Ceil(float64(xAxisGridWidth)*timeDeltaToPaddingRelation)) -} - -func (self *RunChart) getMaxValueLength() int { - - maxValueLength := 0 - - for _, line := range self.lines { - for _, point := range line.points { - l := len(formatValue(point.value, self.precision)) - if l > maxValueLength { - maxValueLength = l - } - } - } - - return maxValueLength -} - -func (self *RunChart) MoveSelection(shift int) { - - if self.scrollMode == Auto { - self.scrollMode = Manual - self.selection = getMidRangeTime(self.grid.timeRange) - return - } else { - self.selection = self.selection.Add(self.grid.timePerPoint * time.Duration(shift)) - if self.selection.After(self.grid.timeRange.max) { - self.selection = self.grid.timeRange.max - } else if self.selection.Before(self.grid.timeRange.min) { - self.selection = self.grid.timeRange.min - } - } - - for i := range self.lines { - self.lines[i].selection = 0 - } -} - -func (self *RunChart) DisableSelection() { - if self.scrollMode == Manual { - self.scrollMode = Auto - return - } -} - -func getMidRangeTime(r TimeRange) time.Time { - delta := r.max.Sub(r.min) - return r.max.Add(-delta / 2) -} - -func formatValue(value float64, precision int) string { - format := "%." + strconv.Itoa(precision) + "f" - return fmt.Sprintf(format, value) -} - -func getValueExtrema(items []TimeLine, timeRange TimeRange) ValueExtrema { - - if len(items) == 0 { - return ValueExtrema{0, 0} - } - - var max, min = -math.MaxFloat64, math.MaxFloat64 - - for _, item := range items { - for _, point := range item.points { - if point.value > max && timeRange.isInRange(point.time) { - max = point.value - } - if point.value < min && timeRange.isInRange(point.time) { - min = point.value - } - } - } - - return ValueExtrema{max: max, min: min} -} - -func (r *TimeRange) isInRange(time time.Time) bool { - return time.After(r.min) && time.Before(r.max) -} - -func getLineValueExtremum(points []TimePoint) ValueExtrema { - - if len(points) == 0 { - return ValueExtrema{0, 0} - } - - var max, min = -math.MaxFloat64, math.MaxFloat64 - - for _, point := range points { - if point.value > max { - max = point.value - } - if point.value < min { - min = point.value - } - } - - return ValueExtrema{max: max, min: min} -} - -func (self *RunChart) getTimeRange(linesCount int) TimeRange { - - if self.scrollMode == Manual { - return self.grid.timeRange - } - - width := time.Duration(self.timescale.Nanoseconds() * int64(linesCount)) - max := time.Now() - - return TimeRange{ - max: max, - min: max.Add(-width), - } -} - -// time duration between grid lines -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 - } -} diff --git a/widgets/runchart/grid.go b/widgets/runchart/grid.go new file mode 100644 index 0000000..9a9f025 --- /dev/null +++ b/widgets/runchart/grid.go @@ -0,0 +1,144 @@ +package runchart + +import ( + "github.com/sqshq/sampler/console" + ui "github.com/sqshq/termui" + "image" + "math" + "time" +) + +type ChartGrid struct { + timeRange TimeRange + timePerPoint time.Duration + valueExtrema ValueExtrema + linesCount int + maxTimeWidth int + minTimeWidth int +} + +func (c *RunChart) newChartGrid() ChartGrid { + + linesCount := (c.Inner.Max.X - c.Inner.Min.X - c.grid.minTimeWidth) / xAxisGridWidth + timeRange := c.getTimeRange(linesCount) + + return ChartGrid{ + timeRange: timeRange, + timePerPoint: c.timescale / time.Duration(xAxisGridWidth), + valueExtrema: getLocalExtrema(c.lines, timeRange), + linesCount: linesCount, + maxTimeWidth: c.Inner.Max.X, + minTimeWidth: c.getMaxValueLength(), + } +} + +func (c *RunChart) renderAxes(buffer *ui.Buffer) { + // draw origin cell + buffer.SetCell( + ui.NewCell(ui.BOTTOM_LEFT, ui.NewStyle(ui.ColorWhite)), + image.Pt(c.Inner.Min.X+c.grid.minTimeWidth, c.Inner.Max.Y-xAxisLabelsHeight-1), + ) + + // draw x axis line + for i := c.grid.minTimeWidth + 1; i < c.Inner.Dx(); i++ { + buffer.SetCell( + ui.NewCell(ui.HORIZONTAL_DASH, ui.NewStyle(ui.ColorWhite)), + image.Pt(i+c.Inner.Min.X, c.Inner.Max.Y-xAxisLabelsHeight-1), + ) + } + + // draw grid lines + for y := 0; y < c.Inner.Dy()-xAxisLabelsHeight-2; y = y + 2 { + for x := 1; x <= c.grid.linesCount; x++ { + buffer.SetCell( + ui.NewCell(ui.VERTICAL_DASH, ui.NewStyle(console.ColorDarkGrey)), + image.Pt(c.grid.maxTimeWidth-x*xAxisGridWidth, y+c.Inner.Min.Y+1), + ) + } + } + + // draw y axis line + for i := 0; i < c.Inner.Dy()-xAxisLabelsHeight-1; i++ { + buffer.SetCell( + ui.NewCell(ui.VERTICAL_DASH, ui.NewStyle(ui.ColorWhite)), + image.Pt(c.Inner.Min.X+c.grid.minTimeWidth, i+c.Inner.Min.Y), + ) + } + + // draw x axis time labels + for i := 1; i <= c.grid.linesCount; i++ { + labelTime := c.grid.timeRange.max.Add(time.Duration(-i) * c.timescale) + buffer.SetString( + labelTime.Format("15:04:05"), + ui.NewStyle(ui.ColorWhite), + image.Pt(c.grid.maxTimeWidth-xAxisLabelsWidth/2-i*(xAxisGridWidth), c.Inner.Max.Y-1), + ) + } + + // draw y axis labels + if c.grid.valueExtrema.max != c.grid.valueExtrema.min { + labelsCount := (c.Inner.Dy() - xAxisLabelsHeight - 1) / (yAxisLabelsIndent + yAxisLabelsHeight) + valuePerY := (c.grid.valueExtrema.max - c.grid.valueExtrema.min) / float64(c.Inner.Dy()-xAxisLabelsHeight-3) + for i := 0; i < int(labelsCount); i++ { + value := c.grid.valueExtrema.max - (valuePerY * float64(i) * (yAxisLabelsIndent + yAxisLabelsHeight)) + buffer.SetString( + formatValue(value, c.precision), + ui.NewStyle(ui.ColorWhite), + image.Pt(c.Inner.Min.X, 1+c.Inner.Min.Y+i*(yAxisLabelsIndent+yAxisLabelsHeight)), + ) + } + } else { + buffer.SetString( + formatValue(c.grid.valueExtrema.max, c.precision), + ui.NewStyle(ui.ColorWhite), + image.Pt(c.Inner.Min.X, c.Inner.Min.Y+c.Inner.Dy()/2)) + } +} + +func (c *RunChart) getTimeRange(linesCount int) TimeRange { + + if c.mode == Pinpoint { + return c.grid.timeRange + } + + width := time.Duration(c.timescale.Nanoseconds() * int64(linesCount)) + max := time.Now() + + return TimeRange{ + max: max, + min: max.Add(-width), + } +} + +func getLocalExtrema(items []TimeLine, timeRange TimeRange) ValueExtrema { + + if len(items) == 0 { + return ValueExtrema{0, 0} + } + + var max, min = -math.MaxFloat64, math.MaxFloat64 + + for _, item := range items { + started := false + for i := len(item.points) - 1; i > 0; i-- { + point := item.points[i] + if timeRange.isInRange(point.time) { + started = true + } else if started == true && !timeRange.isInRange(point.time) { + break + } + if point.value > max && timeRange.isInRange(point.time) { + max = point.value + } + if point.value < min && timeRange.isInRange(point.time) { + min = point.value + } + } + } + + return ValueExtrema{max: max, min: min} +} + +func (r *TimeRange) isInRange(time time.Time) bool { + return time.After(r.min) && time.Before(r.max) +} diff --git a/widgets/runchart/legend.go b/widgets/runchart/legend.go new file mode 100644 index 0000000..bfccd41 --- /dev/null +++ b/widgets/runchart/legend.go @@ -0,0 +1,103 @@ +package runchart + +import ( + "fmt" + ui "github.com/sqshq/termui" + "image" + "math" +) + +const ( + xAxisLegendIndent = 10 + yAxisLegendIndent = 1 + heightWithLabelOnly = 3 + heightWithDetails = 7 +) + +type Legend struct { + Enabled bool + Details bool +} + +func (c *RunChart) renderLegend(buffer *ui.Buffer, rectangle image.Rectangle) { + + if !c.legend.Enabled { + return + } + + height := heightWithLabelOnly + if c.legend.Details { + height = heightWithDetails + } + + rowCount := (c.Dx() - yAxisLegendIndent) / (height + yAxisLegendIndent) + columnCount := int(math.Ceil(float64(len(c.lines)) / float64(rowCount))) + columnWidth := getColumnWidth(c.lines, c.precision) + + for col := 0; col < columnCount; col++ { + for row := 0; row < rowCount; row++ { + + lineIndex := row + rowCount*col + if len(c.lines) <= lineIndex { + break + } + + line := c.lines[row+rowCount*col] + extrema := getLineValueExtrema(line.points) + x := c.Inner.Max.X - (columnWidth+xAxisLegendIndent)*(col+1) + y := c.Inner.Min.Y + yAxisLegendIndent + row*(height) + + titleStyle := ui.NewStyle(line.color) + detailsStyle := ui.NewStyle(ui.ColorWhite) + + buffer.SetString(string(ui.DOT), titleStyle, image.Pt(x-2, y)) + buffer.SetString(line.label, titleStyle, image.Pt(x, y)) + + if !c.legend.Details { + continue + } + + details := [4]string{ + fmt.Sprintf("cur %s", formatValue(line.points[len(line.points)-1].value, c.precision)), + fmt.Sprintf("max %s", formatValue(extrema.max, c.precision)), + fmt.Sprintf("min %s", formatValue(extrema.min, c.precision)), + fmt.Sprintf("dif %s", formatValue(1, c.precision)), + } + + for i, detail := range details { + buffer.SetString(detail, detailsStyle, image.Pt(x, y+i+yAxisLegendIndent)) + } + } + } +} + +func getColumnWidth(lines []TimeLine, precision int) int { + width := len(formatValue(0, precision)) + for _, line := range lines { + if len(line.label) > width { + width = len(line.label) + } + } + return width +} + +// TODO remove and use the one from line +func getLineValueExtrema(points []TimePoint) ValueExtrema { + + if len(points) == 0 { + return ValueExtrema{0, 0} + } + + var max, min = -math.MaxFloat64, math.MaxFloat64 + + for _, point := range points { + if point.value > max { + max = point.value + } + if point.value < min { + min = point.value + } + } + + return ValueExtrema{max: max, min: min} +} diff --git a/widgets/runchart/runchart.go b/widgets/runchart/runchart.go new file mode 100644 index 0000000..2a44025 --- /dev/null +++ b/widgets/runchart/runchart.go @@ -0,0 +1,338 @@ +package runchart + +import ( + "fmt" + "github.com/sqshq/sampler/console" + "github.com/sqshq/sampler/data" + "image" + "math" + "strconv" + "sync" + "time" + + ui "github.com/sqshq/termui" +) + +const ( + xAxisLabelsHeight = 1 + xAxisLabelsWidth = 8 + xAxisLabelsIndent = 2 + xAxisGridWidth = xAxisLabelsIndent + xAxisLabelsWidth + yAxisLabelsHeight = 1 + yAxisLabelsIndent = 1 + + historyReserveMin = 20 + + xBrailleMultiplier = 2 + yBrailleMultiplier = 4 +) + +type Mode int + +const ( + Default Mode = 0 + Pinpoint Mode = 1 +) + +type RunChart struct { + ui.Block + lines []TimeLine + grid ChartGrid + timescale time.Duration + mutex *sync.Mutex + mode Mode + selection time.Time + precision int + legend Legend +} + +type TimePoint struct { + value float64 + time time.Time + coordinate int +} + +type TimeLine struct { + points []TimePoint + extrema ValueExtrema + color ui.Color + label string + selection int +} + +type TimeRange struct { + max time.Time + min time.Time +} + +type ValueExtrema struct { + max float64 + min float64 +} + +func NewRunChart(title string, precision int, refreshRateMs int, legend Legend) *RunChart { + block := *ui.NewBlock() + block.Title = title + return &RunChart{ + Block: block, + lines: []TimeLine{}, + timescale: calculateTimescale(refreshRateMs), + mutex: &sync.Mutex{}, + precision: precision, + mode: Default, + legend: legend, + } +} + +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+1, c.Inner.Min.Y, + c.Inner.Max.X, c.Inner.Max.Y-xAxisLabelsHeight-1, + ) + + c.renderAxes(buffer) + c.renderLines(buffer, drawArea) + c.renderLegend(buffer, drawArea) + c.mutex.Unlock() +} + +func (c *RunChart) ConsumeSample(sample data.Sample) { + + float, err := strconv.ParseFloat(sample.Value, 64) + + if err != nil { + // TODO visual notification + check sample.Error + } + + c.mutex.Lock() + + lineIndex := -1 + + for i, line := range c.lines { + if line.label == sample.Label { + lineIndex = i + } + } + + if lineIndex == -1 { + line := &TimeLine{ + points: []TimePoint{}, + color: sample.Color, + label: sample.Label, + } + c.lines = append(c.lines, *line) + lineIndex = len(c.lines) - 1 + } + + line := c.lines[lineIndex] + timePoint := c.newTimePoint(float) + line.points = append(line.points, timePoint) + c.lines[lineIndex] = line + + c.trimOutOfRangeValues() + 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] + delta := ui.AbsInt(c.calculateTimeCoordinate(probe.time) - probe.coordinate) + + for i, line := range c.lines { + + xPoint := make(map[int]image.Point) + xOrder := make([]int, 0) + + if line.selection != 0 { + line.selection -= delta + c.lines[i].selection = line.selection + } + + 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 + } + + if line.selection == 0 { + if len(line.points) > j+1 && ui.AbsInt(timePoint.coordinate-selectionCoordinate) > ui.AbsInt(line.points[j+1].coordinate-selectionCoordinate) { + selectionPoints[i] = point + } + } else { + if timePoint.coordinate == line.selection { + selectionPoints[i] = point + } + } + + 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]] + } + + canvas.Line( + braillePoint(previousPoint), + braillePoint(currentPoint), + line.color, + ) + } + } + + canvas.Draw(buffer) + + if c.mode == Pinpoint { + for lineIndex, point := range selectionPoints { + buffer.SetCell(ui.NewCell(console.SymbolSelection, ui.NewStyle(c.lines[lineIndex].color)), point) + if c.lines[lineIndex].selection == 0 { + c.lines[lineIndex].selection = point.X + } + } + } +} + +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)) +} + +// TODO add boundaries for values in range +func (c *RunChart) getMaxValueLength() int { + + maxValueLength := 0 + + for _, line := range c.lines { + for _, point := range line.points { + l := len(formatValue(point.value, c.precision)) + if l > maxValueLength { + maxValueLength = l + } + } + } + + return maxValueLength +} + +func (c *RunChart) MoveSelection(shift int) { + + if c.mode == Default { + c.mode = Pinpoint + c.selection = getMidRangeTime(c.grid.timeRange) + return + } else { + 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 + } + } + + for i := range c.lines { + c.lines[i].selection = 0 + } +} + +func (c *RunChart) DisableSelection() { + if c.mode == Pinpoint { + c.mode = Default + return + } +} + +func getMidRangeTime(r TimeRange) time.Time { + delta := r.max.Sub(r.min) + return r.max.Add(-delta / 2) +} + +func formatValue(value float64, precision int) string { + format := "%." + strconv.Itoa(precision) + "f" + return fmt.Sprintf(format, value) +} + +// time duration between grid lines +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 + } +} + +func braillePoint(point image.Point) image.Point { + return image.Point{X: point.X * xBrailleMultiplier, Y: point.Y * yBrailleMultiplier} +}