From e2b478657ad41a4f2bb896f1659f913e06246929 Mon Sep 17 00:00:00 2001 From: sqshq Date: Fri, 1 Feb 2019 23:39:34 -0500 Subject: [PATCH] runchart grid lines and labels calibration --- config.yml | 5 +- config/config.go | 4 +- config/default.go | 8 +- console/console.go | 28 +++ settings/color.go => console/palette.go | 2 +- {settings => event}/event.go | 2 +- event/handler.go | 56 ++++++ main.go | 68 ++----- widgets/component.go | 7 + widgets/layout.go | 17 +- widgets/runchart.go | 229 +++++++++++++++--------- 11 files changed, 272 insertions(+), 154 deletions(-) create mode 100644 console/console.go rename settings/color.go => console/palette.go (97%) rename {settings => event}/event.go (94%) create mode 100644 event/handler.go diff --git a/config.yml b/config.yml index 4e388cd..fb754d7 100644 --- a/config.yml +++ b/config.yml @@ -22,9 +22,8 @@ run-charts: y: 15 - title: MONGO-COUNT data: - - label: posts - script: mongo --quiet --host=localhost blog --eval "db.getCollection('post').find({}).size()" | grep 2 - color: 3 + - label: POSTS + script: mongo --quiet --host=localhost blog --eval "db.getCollection('post').find({}).size()" position: x: 15 y: 0 diff --git a/config/config.go b/config/config.go index 61473db..e20e729 100644 --- a/config/config.go +++ b/config/config.go @@ -1,8 +1,8 @@ package config import ( + "github.com/sqshq/vcmd/console" "github.com/sqshq/vcmd/data" - "github.com/sqshq/vcmd/settings" . "github.com/sqshq/vcmd/widgets" "gopkg.in/yaml.v2" "io/ioutil" @@ -10,7 +10,7 @@ import ( ) type Config struct { - Theme settings.Theme `yaml:"theme"` + Theme console.Theme `yaml:"theme"` RunCharts []RunChartConfig `yaml:"run-charts"` } diff --git a/config/default.go b/config/default.go index 641e88a..3448497 100644 --- a/config/default.go +++ b/config/default.go @@ -1,11 +1,13 @@ package config -import "github.com/sqshq/vcmd/settings" +import ( + "github.com/sqshq/vcmd/console" +) const ( defaultRefreshRateMs = 300 defaultTimeScaleSec = 1 - defaultTheme = settings.ThemeDark + defaultTheme = console.ThemeDark ) func (self *Config) setDefaultValues() { @@ -31,7 +33,7 @@ func (config *Config) setDefaultLayout() { func (config *Config) setDefaultColors() { - palette := settings.GetPalette(config.Theme) + palette := console.GetPalette(config.Theme) for i, chart := range config.RunCharts { for j, item := range chart.Items { diff --git a/console/console.go b/console/console.go new file mode 100644 index 0000000..85e3219 --- /dev/null +++ b/console/console.go @@ -0,0 +1,28 @@ +package console + +import ( + "fmt" + ui "github.com/sqshq/termui" + "log" + "time" +) + +const ( + RenderRate = 30 * time.Millisecond + Title = "vcmd" +) + +type Console struct{} + +func (self *Console) Init() { + + fmt.Printf("\033]0;%s\007", Title) + + if err := ui.Init(); err != nil { + log.Fatalf("failed to initialize termui: %v", err) + } +} + +func (self *Console) Close() { + ui.Close() +} diff --git a/settings/color.go b/console/palette.go similarity index 97% rename from settings/color.go rename to console/palette.go index 541b188..71f4f1a 100644 --- a/settings/color.go +++ b/console/palette.go @@ -1,4 +1,4 @@ -package settings +package console import ( "fmt" diff --git a/settings/event.go b/event/event.go similarity index 94% rename from settings/event.go rename to event/event.go index 037b1b8..21b31ca 100644 --- a/settings/event.go +++ b/event/event.go @@ -1,4 +1,4 @@ -package settings +package event type Event string diff --git a/event/handler.go b/event/handler.go new file mode 100644 index 0000000..c06763a --- /dev/null +++ b/event/handler.go @@ -0,0 +1,56 @@ +package event + +import ( + ui "github.com/sqshq/termui" + "github.com/sqshq/vcmd/widgets" + "time" +) + +type Handler struct { + Layout *widgets.Layout + RenderEvents <-chan time.Time + ConsoleEvents <-chan ui.Event +} + +func (self *Handler) HandleEvents() { + + pause := false + + for { + select { + case <-self.RenderEvents: + if !pause { + ui.Render(self.Layout) + } + case e := <-self.ConsoleEvents: + switch e.ID { + case EventQuit, EventExit: + return + case EventPause: + pause = !pause + case EventResize: + payload := e.Payload.(ui.Resize) + self.Layout.ChangeDimensions(payload.Width, payload.Height) + case EventMouseClick: + payload := e.Payload.(ui.Mouse) + self.handleMouseClick(payload.X, payload.Y) + case EventKeyboardLeft: + // here we are going to move selection (special type of layout item) + //layout.GetItem("").Move(-1, 0) + case EventKeyboardRight: + //layout.GetItem(0).Move(1, 0) + case EventKeyboardDown: + //layout.GetItem(0).Move(0, 1) + case EventKeyboardUp: + //layout.GetItem(0).Move(0, -1) + } + } + } +} + +func (self *Handler) handleMouseClick(x, y int) { + for _, chart := range self.Layout.GetComponents(widgets.TypeRunChart) { + runChart := chart.(*widgets.RunChart) + runChart.SelectValue(x, y) + } +} diff --git a/main.go b/main.go index 77e5299..378a01d 100644 --- a/main.go +++ b/main.go @@ -3,77 +3,37 @@ package main import ( ui "github.com/sqshq/termui" "github.com/sqshq/vcmd/config" + "github.com/sqshq/vcmd/console" "github.com/sqshq/vcmd/data" - "github.com/sqshq/vcmd/settings" + "github.com/sqshq/vcmd/event" "github.com/sqshq/vcmd/widgets" - "log" "time" ) func main() { - print("\033]0;vcmd\007") - cfg := config.Load("/Users/sqshq/Go/src/github.com/sqshq/vcmd/config.yml") + csl := console.Console{} + csl.Init() + defer csl.Close() - if err := ui.Init(); err != nil { - log.Fatalf("failed to initialize termui: %v", err) - } - - defer ui.Close() - events := ui.PollEvents() - - pollers := make([]data.Poller, 0) - lout := widgets.NewLayout(ui.TerminalDimensions()) + layout := widgets.NewLayout(ui.TerminalDimensions()) for _, chartConfig := range cfg.RunCharts { chart := widgets.NewRunChart(chartConfig.Title) - lout.AddItem(chart, chartConfig.Position, chartConfig.Size) + layout.AddComponent(chart, chartConfig.Position, chartConfig.Size, widgets.TypeRunChart) for _, item := range chartConfig.Items { - pollers = append(pollers, - data.NewPoller(chart, item, chartConfig.RefreshRateMs)) + data.NewPoller(chart, item, chartConfig.RefreshRateMs) } } - ticker := time.NewTicker(30 * time.Millisecond) - pause := false - - for { - select { - case e := <-events: - switch e.ID { - case settings.EventQuit, settings.EventExit: - return - case settings.EventResize: - payload := e.Payload.(ui.Resize) - lout.ChangeDimensions(payload.Width, payload.Height) - case settings.EventMouseClick: - //payload := e.Payload.(ui.Mouse) - //x, y := payload.X, payload.Y - //log.Printf("x: %v, y: %v", x, y) - } - switch e.Type { - case ui.KeyboardEvent: - switch e.ID { - case settings.EventKeyboardLeft: - // here we are going to move selection (special type of layout item) - //lout.GetItem("").Move(-1, 0) - case settings.EventKeyboardRight: - //lout.GetItem(0).Move(1, 0) - case settings.EventKeyboardDown: - //lout.GetItem(0).Move(0, 1) - case settings.EventKeyboardUp: - //lout.GetItem(0).Move(0, -1) - case settings.EventPause: - pause = !pause - } - } - case <-ticker.C: - if !pause { - ui.Render(lout) - } - } + handler := event.Handler{ + Layout: layout, + RenderEvents: time.NewTicker(console.RenderRate).C, + ConsoleEvents: ui.PollEvents(), } + + handler.HandleEvents() } diff --git a/widgets/component.go b/widgets/component.go index 8bfdef3..9b64a68 100644 --- a/widgets/component.go +++ b/widgets/component.go @@ -8,8 +8,15 @@ type Component struct { Drawable Drawable Position Position Size Size + Type ComponentType } +type ComponentType string + +const ( + TypeRunChart ComponentType = "runchart" +) + type Position struct { X int `yaml:"x"` Y int `yaml:"y"` diff --git a/widgets/layout.go b/widgets/layout.go index c0015e0..637e41c 100644 --- a/widgets/layout.go +++ b/widgets/layout.go @@ -25,8 +25,21 @@ func NewLayout(width, height int) *Layout { } } -func (self *Layout) AddItem(drawable Drawable, position Position, size Size) { - self.components = append(self.components, Component{drawable, position, size}) +func (self *Layout) AddComponent(drawable Drawable, position Position, size Size, Type ComponentType) { + self.components = append(self.components, Component{drawable, position, size, Type}) +} + +func (self *Layout) GetComponents(Type ComponentType) []Drawable { + + var components []Drawable + + for _, component := range self.components { + if component.Type == Type { + components = append(components, component.Drawable) + } + } + + return components } func (self *Layout) ChangeDimensions(width, height int) { diff --git a/widgets/runchart.go b/widgets/runchart.go index 916944c..b853649 100644 --- a/widgets/runchart.go +++ b/widgets/runchart.go @@ -2,8 +2,8 @@ package widgets import ( "fmt" + "github.com/sqshq/vcmd/console" "github.com/sqshq/vcmd/data" - "github.com/sqshq/vcmd/settings" "image" "log" "math" @@ -18,16 +18,18 @@ const ( xAxisLabelsHeight = 1 xAxisLabelsWidth = 8 xAxisLabelsGap = 2 - yAxisLabelsWidth = 5 + yAxisLabelsHeight = 1 yAxisLabelsGap = 1 xAxisLegendWidth = 15 ) type RunChart struct { Block - lines []TimeLine - grid ChartGrid - mutex *sync.Mutex + lines []TimeLine + grid ChartGrid + precision int + selection time.Time + mutex *sync.Mutex } type TimePoint struct { @@ -45,6 +47,7 @@ type ChartGrid struct { paddingDuration time.Duration paddingWidth int maxTimeWidth int + minTimeWidth int valueExtremum ValueExtremum timeExtremum TimeExtremum } @@ -63,22 +66,24 @@ func NewRunChart(title string) *RunChart { block := *NewBlock() block.Title = title return &RunChart{ - Block: block, - lines: []TimeLine{}, - mutex: &sync.Mutex{}, + Block: block, + lines: []TimeLine{}, + mutex: &sync.Mutex{}, + precision: 2, // TODO config } } func (self *RunChart) newChartGrid() ChartGrid { - linesCount := (self.Inner.Max.X - self.Inner.Min.X) / (xAxisLabelsGap + xAxisLabelsWidth) + linesCount := (self.Inner.Max.X - self.Inner.Min.X - self.grid.minTimeWidth) / (xAxisLabelsGap + xAxisLabelsWidth) paddingDuration := time.Duration(time.Second) // TODO support others and/or adjust automatically depending on refresh rate return ChartGrid{ linesCount: linesCount, paddingDuration: paddingDuration, paddingWidth: xAxisLabelsGap + xAxisLabelsWidth, - maxTimeWidth: self.Inner.Max.X - xAxisLabelsWidth/2 - xAxisLabelsGap, + maxTimeWidth: self.Inner.Max.X, + minTimeWidth: self.getMaxValueLength(), timeExtremum: GetTimeExtremum(linesCount, paddingDuration), valueExtremum: GetChartValueExtremum(self.lines), } @@ -92,7 +97,7 @@ func (self *RunChart) Draw(buf *Buffer) { self.renderAxes(buf) drawArea := image.Rect( - self.Inner.Min.X+yAxisLabelsWidth+1, self.Inner.Min.Y, + self.Inner.Min.X+self.grid.minTimeWidth+1, self.Inner.Min.Y, self.Inner.Max.X, self.Inner.Max.Y-xAxisLabelsHeight-1, ) @@ -105,7 +110,7 @@ func (self *RunChart) ConsumeValue(item data.Item, value string) { float, err := strconv.ParseFloat(value, 64) if err != nil { - log.Fatalf("Expected float number, but got %v", value) // TODO visual notification + log.Printf("Expected float number, but got %v", value) // TODO visual notification } timePoint := TimePoint{Value: float, Time: time.Now()} @@ -136,12 +141,22 @@ func (self *RunChart) ConsumeError(item data.Item, err error) { // TODO visual notification } +func (self *RunChart) SelectValue(x int, y int) { + // TODO instead of that, find actual time for the given X + // + make sure that Y is within the given chart + // once ensured, set "selected time" into the chart structure + // self.selection = image.Point{X: x, Y: y} +} + func (self *RunChart) trimOutOfRangeValues() { + + minRangeTime := self.grid.timeExtremum.min.Add(-self.grid.paddingDuration * 10) + for i, item := range self.lines { lastOutOfRangeValueIndex := -1 - for j, timePoint := range item.points { - if !self.isTimePointInRange(timePoint) { + for j, point := range item.points { + if point.Time.Before(minRangeTime) { lastOutOfRangeValueIndex = j } } @@ -153,60 +168,6 @@ func (self *RunChart) trimOutOfRangeValues() { } } -func (self *RunChart) renderAxes(buffer *Buffer) { - // draw origin cell - buffer.SetCell( - NewCell(BOTTOM_LEFT, NewStyle(ColorWhite)), - image.Pt(self.Inner.Min.X+yAxisLabelsWidth, self.Inner.Max.Y-xAxisLabelsHeight-1), - ) - - // draw x axis line - for i := yAxisLabelsWidth + 1; i < self.Inner.Dx(); i++ { - buffer.SetCell( - NewCell(HORIZONTAL_DASH, NewStyle(ColorWhite)), - image.Pt(i+self.Inner.Min.X, self.Inner.Max.Y-xAxisLabelsHeight-1), - ) - } - - // draw grid - for y := 0; y < self.Inner.Dy()-xAxisLabelsHeight-1; y = y + 2 { - for x := 0; x < self.grid.linesCount; x++ { - buffer.SetCell( - NewCell(VERTICAL_DASH, NewStyle(settings.ColorDarkGrey)), - image.Pt(self.grid.maxTimeWidth-x*self.grid.paddingWidth, y+self.Inner.Min.Y+1), - ) - } - } - - // draw y axis line - for i := 0; i < self.Inner.Dy()-xAxisLabelsHeight-1; i++ { - buffer.SetCell( - NewCell(VERTICAL_DASH, NewStyle(ColorWhite)), - image.Pt(self.Inner.Min.X+yAxisLabelsWidth, i+self.Inner.Min.Y), - ) - } - - // draw x axis time labels - for i := 0; i < self.grid.linesCount; i++ { - labelTime := self.grid.timeExtremum.max.Add(time.Duration(-i) * time.Second) - buffer.SetString( - labelTime.Format("15:04:05"), - NewStyle(ColorWhite), - image.Pt(self.grid.maxTimeWidth-xAxisLabelsWidth/2-i*(self.grid.paddingWidth), self.Inner.Max.Y-1), - ) - } - - // draw y axis labels - verticalScale := self.grid.valueExtremum.max - self.grid.valueExtremum.min/float64(self.Inner.Dy()-xAxisLabelsHeight-1) - for i := 1; i*(yAxisLabelsGap+1) <= self.Inner.Dy()-1; i++ { - buffer.SetString( - fmt.Sprintf("%.3f", float64(i)*self.grid.valueExtremum.min*verticalScale*(yAxisLabelsGap+1)), - NewStyle(ColorWhite), - image.Pt(self.Inner.Min.X, self.Inner.Max.Y-(i*(yAxisLabelsGap+1))-2), - ) - } -} - func (self *RunChart) renderItems(buffer *Buffer, drawArea image.Rectangle) { canvas := NewCanvas() @@ -219,22 +180,29 @@ func (self *RunChart) renderItems(buffer *Buffer, drawArea image.Rectangle) { for _, point := range line.points { - if !self.isTimePointInRange(point) { - continue + timeDeltaWithGridMaxTime := self.grid.timeExtremum.max.Sub(point.Time).Nanoseconds() + timeDeltaToPaddingRelation := float64(timeDeltaWithGridMaxTime) / float64(self.grid.paddingDuration.Nanoseconds()) + x := self.grid.maxTimeWidth - (int(float64(self.grid.paddingWidth) * timeDeltaToPaddingRelation)) + + var y int + if self.grid.valueExtremum.max-self.grid.valueExtremum.min == 0 { + y = (drawArea.Dy() - 2) / 2 + } else { + valuePerY := (self.grid.valueExtremum.max - self.grid.valueExtremum.min) / float64(drawArea.Dy()-2) + y = int(float64(point.Value-self.grid.valueExtremum.min) / valuePerY) } - timeDeltaWithGridMaxTime := self.grid.timeExtremum.max.Sub(point.Time) - deltaToPaddingRelation := float64(timeDeltaWithGridMaxTime.Nanoseconds()) / float64(self.grid.paddingDuration.Nanoseconds()) - x := self.grid.maxTimeWidth - (int(float64(self.grid.paddingWidth) * deltaToPaddingRelation)) - - valuePerYDot := (self.grid.valueExtremum.max - self.grid.valueExtremum.min) / float64(drawArea.Dy()-1) - y := int(float64(point.Value-self.grid.valueExtremum.min) / valuePerYDot) + point := image.Pt(x, drawArea.Max.Y-y-1) if _, exists := xToPoint[x]; exists { continue } - xToPoint[x] = image.Pt(x, drawArea.Max.Y-y-1) + if !point.In(drawArea) { + continue + } + + xToPoint[x] = point pointsOrder = append(pointsOrder, x) } @@ -249,29 +217,97 @@ func (self *RunChart) renderItems(buffer *Buffer, drawArea image.Rectangle) { previousPoint = xToPoint[pointsOrder[i-1]] } - //buffer.SetCell( - // NewCell(self.DotRune, NewStyle(SelectColor(self.LineColors, 0))), - // currentPoint, - //) - canvas.Line( braillePoint(previousPoint), braillePoint(currentPoint), line.item.Color, ) } + + //if point, exists := xToPoint[self.selection.X]; exists { + // buffer.SetCell( + // NewCell(DOT, NewStyle(line.item.Color)), + // point, + // ) + // log.Printf("EXIST!") + //} else { + // //log.Printf("DOES NOT EXIST") + //} } canvas.Draw(buffer) } +func (self *RunChart) renderAxes(buffer *Buffer) { + // draw origin cell + buffer.SetCell( + NewCell(BOTTOM_LEFT, NewStyle(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( + NewCell(HORIZONTAL_DASH, NewStyle(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( + NewCell(VERTICAL_DASH, NewStyle(console.ColorDarkGrey)), + image.Pt(self.grid.maxTimeWidth-x*self.grid.paddingWidth, y+self.Inner.Min.Y+1), + ) + } + } + + // draw y axis line + for i := 0; i < self.Inner.Dy()-xAxisLabelsHeight-1; i++ { + buffer.SetCell( + NewCell(VERTICAL_DASH, NewStyle(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.timeExtremum.max.Add(time.Duration(-i) * time.Second) + buffer.SetString( + labelTime.Format("15:04:05"), + NewStyle(ColorWhite), + image.Pt(self.grid.maxTimeWidth-xAxisLabelsWidth/2-i*(self.grid.paddingWidth), self.Inner.Max.Y-1), + ) + } + + // draw y axis labels + if self.grid.valueExtremum.max != self.grid.valueExtremum.min { + labelsCount := (self.Inner.Dy() - xAxisLabelsHeight - 1) / (yAxisLabelsGap + yAxisLabelsHeight) + valuePerY := (self.grid.valueExtremum.max - self.grid.valueExtremum.min) / float64(self.Inner.Dy()-xAxisLabelsHeight-3) + for i := 0; i < int(labelsCount); i++ { + value := self.grid.valueExtremum.max - (valuePerY * float64(i) * (yAxisLabelsGap + yAxisLabelsHeight)) + buffer.SetString( + formatValue(value, self.precision), + NewStyle(ColorWhite), + image.Pt(self.Inner.Min.X, 1+self.Inner.Min.Y+i*(yAxisLabelsGap+yAxisLabelsHeight)), + ) + } + } else { + buffer.SetString( + formatValue(self.grid.valueExtremum.max, self.precision), + NewStyle(ColorWhite), + image.Pt(self.Inner.Min.X, self.Inner.Dy()/2)) + } +} + func (self *RunChart) renderLegend(buffer *Buffer, rectangle image.Rectangle) { for i, line := range self.lines { extremum := GetLineValueExtremum(line.points) buffer.SetString( - fmt.Sprintf("•"), + string(DOT), NewStyle(line.item.Color), image.Pt(self.Inner.Max.X-xAxisLegendWidth-2, self.Inner.Min.Y+1+i*5), ) @@ -281,25 +317,42 @@ func (self *RunChart) renderLegend(buffer *Buffer, rectangle image.Rectangle) { image.Pt(self.Inner.Max.X-xAxisLegendWidth, self.Inner.Min.Y+1+i*5), ) buffer.SetString( - fmt.Sprintf("max %.3f", extremum.max), + fmt.Sprintf("cur %s", formatValue(line.points[len(line.points)-1].Value, self.precision)), NewStyle(ColorWhite), image.Pt(self.Inner.Max.X-xAxisLegendWidth, self.Inner.Min.Y+2+i*5), ) buffer.SetString( - fmt.Sprintf("min %.3f", extremum.min), + fmt.Sprintf("max %s", formatValue(extremum.max, self.precision)), NewStyle(ColorWhite), image.Pt(self.Inner.Max.X-xAxisLegendWidth, self.Inner.Min.Y+3+i*5), ) buffer.SetString( - fmt.Sprintf("cur %.3f", line.points[len(line.points)-1].Value), + fmt.Sprintf("min %s", formatValue(extremum.min, self.precision)), NewStyle(ColorWhite), image.Pt(self.Inner.Max.X-xAxisLegendWidth, self.Inner.Min.Y+4+i*5), ) } } -func (self *RunChart) isTimePointInRange(point TimePoint) bool { - return point.Time.After(self.grid.timeExtremum.min.Add(self.grid.paddingDuration)) +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 formatValue(value float64, precision int) string { + format := " %." + strconv.Itoa(precision) + "f" + return fmt.Sprintf(format, value) } func GetChartValueExtremum(items []TimeLine) ValueExtremum {