diff --git a/app/config/linechart.go b/app/config/linechart.go deleted file mode 100644 index 1e30342..0000000 --- a/app/config/linechart.go +++ /dev/null @@ -1,9 +0,0 @@ -package config - -type LineChartConfig struct { - Title string `yaml:"title"` - Data []Data `yaml:"data"` - Position Position `yaml:"position"` - RefreshRateMs int `yaml:"refresh-rate-ms"` - Scale string `yaml:"scale"` -} diff --git a/app/main.go b/app/main.go deleted file mode 100644 index eda8348..0000000 --- a/app/main.go +++ /dev/null @@ -1,121 +0,0 @@ -package main - -import ( - ui "github.com/sqshq/termui" - "github.com/sqshq/termui/widgets" - "github.com/sqshq/vcmd/app/config" - "log" - "math" -) - -func main() { - - if err := ui.Init(); err != nil { - log.Fatalf("failed to initialize termui: %v", err) - } - - defer ui.Close() - - cfg := config.Load("/Users/sqshq/config.yml") - - p1 := widgets.NewPlot() - p1.Title = "dot-mode line Chart" - p1.Marker = widgets.MarkerDot - p1.Data = [][]float64{ - {0, 1, 2, 3}, - {4, 5, 6, 7}, - } - p1.SetRect(50, 0, 100, 10) - p1.DataLabels = []string{"hello"} - p1.DotRune = '+' - p1.AxesColor = ui.ColorWhite - p1.LineColors[0] = ui.ColorCyan - p1.DrawDirection = widgets.DrawLeft - - for _, linechart := range cfg.LineCharts { - for _, data := range linechart.Data { - value, _ := data.NextValue() - log.Printf("%s: %s - %v", linechart.Title, data.Label, value) - } - } - - ui.Render(p1) - - uiEvents := ui.PollEvents() - for { - e := <-uiEvents - switch e.ID { - case "q", "": - return - } - } -} - -func printChart() { - if err := ui.Init(); err != nil { - log.Fatalf("failed to initialize termui: %v", err) - } - defer ui.Close() - - sinData := func() [][]float64 { - n := 220 - data := make([][]float64, 2) - data[0] = make([]float64, n) - data[1] = make([]float64, n) - for i := 0; i < n; i++ { - data[0][i] = 1 + math.Sin(float64(i)/5) - data[1][i] = 1 + math.Cos(float64(i)/5) - } - return data - }() - - p0 := widgets.NewPlot() - p0.Title = "braille-mode Line Chart" - p0.Data = sinData - p0.SetRect(0, 0, 50, 15) - p0.AxesColor = ui.ColorWhite - p0.LineColors[0] = ui.ColorGreen - - p1 := widgets.NewPlot() - p1.Title = "dot-mode line Chart" - p1.Marker = widgets.MarkerDot - p1.Data = [][]float64{{1, 2, 3, 4, 5}} - p1.SetRect(50, 0, 75, 10) - p1.DotRune = '+' - p1.AxesColor = ui.ColorWhite - p1.LineColors[0] = ui.ColorYellow - p1.DrawDirection = widgets.DrawLeft - - p2 := widgets.NewPlot() - p2.Title = "dot-mode Scatter Plot" - p2.Marker = widgets.MarkerDot - p2.Data = make([][]float64, 2) - p2.Data[0] = []float64{1, 2, 3, 4, 5} - p2.Data[1] = sinData[1][4:] - p2.SetRect(0, 15, 50, 30) - p2.AxesColor = ui.ColorWhite - p2.LineColors[0] = ui.ColorCyan - p2.Type = widgets.ScatterPlot - - p3 := widgets.NewPlot() - p3.Title = "braille-mode Scatter Plot" - p3.Data = make([][]float64, 2) - p3.Data[0] = []float64{1, 2, 3, 4, 5} - p3.Data[1] = sinData[1][4:] - p3.SetRect(45, 15, 80, 30) - p3.AxesColor = ui.ColorWhite - p3.LineColors[0] = ui.ColorCyan - p3.Marker = widgets.MarkerBraille - p3.Type = widgets.ScatterPlot - - ui.Render(p0, p1, p2, p3) - - uiEvents := ui.PollEvents() - for { - e := <-uiEvents - switch e.ID { - case "q", "": - return - } - } -} diff --git a/config.yml b/config.yml new file mode 100644 index 0000000..15981ec --- /dev/null +++ b/config.yml @@ -0,0 +1,24 @@ +theme: dark / bright +line-charts: + - title: curl-latency + data: + - label: example.com + script: curl -o /dev/null -s -w '%{time_total}' http://example.com + color: red + - label: google.com + script: curl -o /dev/null -s -w '%{time_total}' http://google.com + color: yellow + refresh-rate-ms: 100 + style: dots/lines + scale: log + position: + x: 10 + y: 20 + size: + x: 100 + y: 50 + - title: mongo-count + data: + - label: posts + script: mongo --quiet --host=localhost blog --eval "db.getCollection('post').find({}).size()" | grep 2 + color: red diff --git a/app/config/config.go b/config/config.go similarity index 95% rename from app/config/config.go rename to config/config.go index b3bcbe6..60e1a64 100644 --- a/app/config/config.go +++ b/config/config.go @@ -17,7 +17,7 @@ func Load(location string) *Config { log.Fatalf("Can't read config file: %s", location) } - cfg := new(Config) + cfg := new(Config) err = yaml.Unmarshal(yamlFile, cfg) if err != nil { diff --git a/app/config/data.go b/config/data.go similarity index 88% rename from app/config/data.go rename to config/data.go index eff2aea..e66204c 100644 --- a/app/config/data.go +++ b/config/data.go @@ -8,8 +8,8 @@ import ( ) type Data struct { - Label string `yaml:"label"` - Color string `yaml:"color"` + Label string `yaml:"label"` + Color string `yaml:"color"` Script string `yaml:"script"` } diff --git a/config/linechart.go b/config/linechart.go new file mode 100644 index 0000000..95df044 --- /dev/null +++ b/config/linechart.go @@ -0,0 +1,9 @@ +package config + +type LineChartConfig struct { + Title string `yaml:"title"` + Data []Data `yaml:"data"` + Position Position `yaml:"position"` + RefreshRateMs int `yaml:"refresh-rate-ms"` + Scale string `yaml:"scale"` +} diff --git a/app/config/position.go b/config/position.go similarity index 97% rename from app/config/position.go rename to config/position.go index abb8a7b..182b076 100644 --- a/app/config/position.go +++ b/config/position.go @@ -3,4 +3,4 @@ package config type Position struct { X int `yaml:"x"` Y int `yaml:"y"` -} \ No newline at end of file +} diff --git a/app/config/size.go b/config/size.go similarity index 97% rename from app/config/size.go rename to config/size.go index a8d1bf8..0269394 100644 --- a/app/config/size.go +++ b/config/size.go @@ -3,4 +3,4 @@ package config type Size struct { X int `yaml:"x"` Y int `yaml:"y"` -} \ No newline at end of file +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..e92aaf6 --- /dev/null +++ b/main.go @@ -0,0 +1,107 @@ +package main + +import ( + ui "github.com/sqshq/termui" + "github.com/sqshq/vcmd/config" + "github.com/sqshq/vcmd/widgets" + "log" + "time" +) + +func main() { + + cfg := config.Load("/Users/sqshq/Go/src/github.com/sqshq/vcmd/config.yml") + + for _, linechart := range cfg.LineCharts { + for _, data := range linechart.Data { + value, _ := data.NextValue() + log.Printf("%s: %s - %v", linechart.Title, data.Label, value) + } + } + + p1 := widgets.NewTimePlot() + p1.Title = " CURL LATENCY STATISTICS (sec) " + p1.SetRect(0, 20, 148, 40) + p1.LineColors[0] = ui.ColorYellow + p1.Marker = widgets.MarkerBraille + + if err := ui.Init(); err != nil { + //log.Fatalf("failed to initialize termui: %v", err) + } + + defer ui.Close() + uiEvents := ui.PollEvents() + + dataTicker := time.NewTicker(200 * time.Millisecond) + uiTicker := time.NewTicker(50 * time.Millisecond) + + pause := false + + go func() { + for { + select { + case <-dataTicker.C: + if !pause { + value, err := cfg.LineCharts[0].Data[0].NextValue() + if err != nil { + log.Printf("failed to get value: %s", err) + break + } + p1.AddValue(value) + } + } + } + }() + + for { + select { + case e := <-uiEvents: + switch e.ID { + case "q", "": // press 'q' or 'C-c' to quit + return + } + //case "": + // 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: // handle all key presses + //log.Printf("key: %v", e.ID) + switch e.ID { + // TODO refactor + control moving out of range + case "": + rect := p1.GetRect() + min := rect.Min + max := rect.Max + p1.SetRect(min.X-1, min.Y, max.X-1, max.Y) + ui.Clear() + case "": + rect := p1.GetRect() + min := rect.Min + max := rect.Max + p1.SetRect(min.X+1, min.Y, max.X+1, max.Y) + ui.Clear() + case "": + rect := p1.GetRect() + min := rect.Min + max := rect.Max + p1.SetRect(min.X, min.Y+1, max.X, max.Y+1) + ui.Clear() + case "": + rect := p1.GetRect() + min := rect.Min + max := rect.Max + p1.SetRect(min.X, min.Y-1, max.X, max.Y-1) + ui.Clear() + case "p": + pause = !pause + } + } + case <-uiTicker.C: + if !pause { + ui.Render(p1) + } + } + } +} diff --git a/widgets/geom.go b/widgets/geom.go new file mode 100644 index 0000000..18adfe1 --- /dev/null +++ b/widgets/geom.go @@ -0,0 +1,18 @@ +package widgets + +import ( + "image" +) + +const ( + xBrailleMultiplier = 2 + yBrailleMultiplier = 4 +) + +func braille(point image.Point) image.Point { + return image.Point{X: point.X * xBrailleMultiplier, Y: point.Y * yBrailleMultiplier} +} + +func deBraille(point image.Point) image.Point { + return image.Point{X: point.X / xBrailleMultiplier, Y: point.Y / yBrailleMultiplier} +} diff --git a/widgets/settings.go b/widgets/settings.go new file mode 100644 index 0000000..f447823 --- /dev/null +++ b/widgets/settings.go @@ -0,0 +1,7 @@ +package widgets + +import "github.com/sqshq/termui" + +const ( + ColorDarkGrey termui.Color = 240 +) diff --git a/widgets/timeplot.go b/widgets/timeplot.go new file mode 100644 index 0000000..ddeacbc --- /dev/null +++ b/widgets/timeplot.go @@ -0,0 +1,265 @@ +package widgets + +import ( + "fmt" + "image" + "sync" + "time" + + . "github.com/sqshq/termui" +) + +type TimePlot struct { + Block + DataLabels []string + MaxValueTimePoint TimePoint + LineColors []Color + ShowAxes bool + DotRune rune + HorizontalScale int + Marker PlotMarker + timePoints []TimePoint + dataMutex *sync.Mutex + grid PlotGrid +} + +const ( + xAxisLabelsHeight = 1 + xAxisLabelsWidth = 8 + xAxisLabelsGap = 2 + yAxisLabelsWidth = 5 + yAxisLabelsGap = 1 +) + +type TimePoint struct { + Value float64 + Time time.Time +} + +type PlotMarker uint + +const ( + MarkerBraille PlotMarker = iota + MarkerDot +) + +func NewTimePlot() *TimePlot { + return &TimePlot{ + Block: *NewBlock(), + LineColors: Theme.Plot.Lines, + DotRune: DOT, + HorizontalScale: 1, + ShowAxes: true, + Marker: MarkerBraille, + timePoints: make([]TimePoint, 0), + dataMutex: &sync.Mutex{}, + } +} + +type PlotGrid struct { + count int + maxTimeX int + maxTime time.Time + minTime time.Time + maxValue float64 + minValue float64 + spacingDuration time.Duration + spacingWidth int +} + +func (self *TimePlot) newPlotGrid() PlotGrid { + + count := (self.Inner.Max.X - self.Inner.Min.X) / (xAxisLabelsGap + xAxisLabelsWidth) + spacingDuration := time.Duration(time.Second) // TODO support others and/or adjust automatically depending on refresh rate + maxTime := time.Now() + minTime := maxTime.Add(-time.Duration(spacingDuration.Nanoseconds() * int64(count))) + maxPoint, minPoint := GetMaxAndMinValueTimePoints(self.timePoints) + + return PlotGrid{ + count: count, + spacingDuration: spacingDuration, + spacingWidth: xAxisLabelsGap + xAxisLabelsWidth, + maxTimeX: self.Inner.Max.X - xAxisLabelsWidth/2 - xAxisLabelsGap, + maxTime: maxTime, + minTime: minTime, + maxValue: maxPoint.Value, + minValue: minPoint.Value, + } +} + +func (self *TimePlot) Draw(buf *Buffer) { + + self.dataMutex.Lock() + self.Block.Draw(buf) + self.grid = self.newPlotGrid() + + if self.ShowAxes { + self.plotAxes(buf) + } + + drawArea := self.Inner + if self.ShowAxes { + drawArea = image.Rect( + self.Inner.Min.X+yAxisLabelsWidth+1, self.Inner.Min.Y, + self.Inner.Max.X, self.Inner.Max.Y-xAxisLabelsHeight-1, + ) + } + + self.renderBraille(buf, drawArea) + self.dataMutex.Unlock() +} + +func (self *TimePlot) AddValue(value float64) { + self.dataMutex.Lock() + self.timePoints = append(self.timePoints, TimePoint{Value: value, Time: time.Now()}) + self.trimOutOfRangeValues() + self.dataMutex.Unlock() +} + +func (self *TimePlot) trimOutOfRangeValues() { + + lastOutOfRangeValueIndex := -1 + + for i, timePoint := range self.timePoints { + if !self.isTimePointInRange(timePoint) { + lastOutOfRangeValueIndex = i + } + } + + if lastOutOfRangeValueIndex > 0 { + self.timePoints = append(self.timePoints[:0], self.timePoints[lastOutOfRangeValueIndex+1:]...) + } +} + +func (self *TimePlot) renderBraille(buf *Buffer, drawArea image.Rectangle) { + + canvas := NewCanvas() + canvas.Rectangle = drawArea + + pointPerX := make(map[int]image.Point) + pointsOrder := make([]int, 0) + + for _, timePoint := range self.timePoints { + + if !self.isTimePointInRange(timePoint) { + continue + } + + timeDeltaWithGridMaxTime := self.grid.maxTime.Sub(timePoint.Time) + deltaToSpacingRelation := float64(timeDeltaWithGridMaxTime.Nanoseconds()) / float64(self.grid.spacingDuration.Nanoseconds()) + x := self.grid.maxTimeX - (int(float64(self.grid.spacingWidth) * deltaToSpacingRelation)) + + valuePerYDot := (self.grid.maxValue - self.grid.minValue) / float64(drawArea.Dy()-1) + y := int(float64(timePoint.Value-self.grid.minValue) / valuePerYDot) + + if _, exists := pointPerX[x]; exists { + continue + } + + pointPerX[x] = image.Pt(x, drawArea.Max.Y-y-1) + pointsOrder = append(pointsOrder, x) + } + + for i, x := range pointsOrder { + + currentPoint := pointPerX[x] + var previousPoint image.Point + + if i == 0 { + previousPoint = currentPoint + } else { + previousPoint = pointPerX[pointsOrder[i-1]] + } + + //buf.SetCell( + // NewCell(self.DotRune, NewStyle(SelectColor(self.LineColors, 0))), + // currentPoint, + //) + + canvas.Line( + braille(previousPoint), + braille(currentPoint), + SelectColor(self.LineColors, 0), //i + ) + } + + canvas.Draw(buf) +} + +func (self *TimePlot) isTimePointInRange(point TimePoint) bool { + return point.Time.After(self.grid.minTime.Add(self.grid.spacingDuration)) +} + +func (self *TimePlot) plotAxes(buf *Buffer) { + // draw origin cell + buf.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++ { + buf.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.count; x++ { + buf.SetCell( + NewCell(VERTICAL_DASH, NewStyle(ColorDarkGrey)), + image.Pt(self.grid.maxTimeX-x*self.grid.spacingWidth, y+self.Inner.Min.Y+1), + ) + } + } + + // draw y axis line + for i := 0; i < self.Inner.Dy()-xAxisLabelsHeight-1; i++ { + buf.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.count; i++ { + labelTime := self.grid.maxTime.Add(time.Duration(-i) * time.Second) + buf.SetString( + labelTime.Format("15:04:05"), + NewStyle(ColorWhite), + image.Pt(self.grid.maxTimeX-xAxisLabelsWidth/2-i*(self.grid.spacingWidth), self.Inner.Max.Y-1), + ) + } + + // draw y axis labels + verticalScale := self.grid.maxValue - self.grid.minValue/float64(self.Inner.Dy()-xAxisLabelsHeight-1) + for i := 1; i*(yAxisLabelsGap+1) <= self.Inner.Dy()-1; i++ { + buf.SetString( + fmt.Sprintf("%.3f", float64(i)*self.grid.minValue*verticalScale*(yAxisLabelsGap+1)), + NewStyle(ColorWhite), + image.Pt(self.Inner.Min.X, self.Inner.Max.Y-(i*(yAxisLabelsGap+1))-2), + ) + } +} + +func GetMaxAndMinValueTimePoints(points []TimePoint) (TimePoint, TimePoint) { + + if len(points) == 0 { + return TimePoint{0, time.Now()}, TimePoint{0, time.Now()} + } + + var max, min = points[0], points[0] + + for _, point := range points { + if point.Value > max.Value { + max = point + } + if point.Value < min.Value { + min = point + } + } + + return max, min +}