diff --git a/Makefile b/Makefile index 27c9814..6df4d2c 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -VERSION = 1.1.2 +VERSION = 1.1.3 APP := jp PACKAGES := $(shell go list -f {{.Dir}} ./...) diff --git a/README.md b/README.md index 9cc8738..1db486d 100644 --- a/README.md +++ b/README.md @@ -34,16 +34,16 @@ Or [download the binary](https://github.com/sgreben/jp/releases/latest) from the ```bash # Linux -curl -LO https://github.com/sgreben/jp/releases/download/1.1.2/jp_1.1.2_linux_x86_64.zip -unzip jp_1.1.2_linux_x86_64.zip +curl -LO https://github.com/sgreben/jp/releases/download/1.1.3/jp_1.1.3_linux_x86_64.zip +unzip jp_1.1.3_linux_x86_64.zip # OS X -curl -LO https://github.com/sgreben/jp/releases/download/1.1.2/jp_1.1.2_osx_x86_64.zip -unzip jp_1.1.2_osx_x86_64.zip +curl -LO https://github.com/sgreben/jp/releases/download/1.1.3/jp_1.1.3_osx_x86_64.zip +unzip jp_1.1.3_osx_x86_64.zip # Windows -curl -LO https://github.com/sgreben/jp/releases/download/1.1.2/jp_1.1.2_windows_x86_64.zip -unzip jp_1.1.2_windows_x86_64.zip +curl -LO https://github.com/sgreben/jp/releases/download/1.1.3/jp_1.1.3_windows_x86_64.zip +unzip jp_1.1.3_windows_x86_64.zip ``` ## Use it diff --git a/cmd/jp/bar.go b/cmd/jp/bar.go index 15dd196..cfc39c2 100644 --- a/cmd/jp/bar.go +++ b/cmd/jp/bar.go @@ -2,37 +2,38 @@ package main import ( "fmt" + "log" "reflect" + "github.com/sgreben/jp/pkg/data" "github.com/sgreben/jp/pkg/draw" "github.com/sgreben/jp/pkg/plot" ) -func barPlotData(xvv, yvv [][]reflect.Value) (x []string, y []float64) { - for _, xv := range xvv { - for i := range xv { - if xv[i].IsValid() && xv[i].CanInterface() { - x = append(x, fmt.Sprint(xv[i].Interface())) - } +func barPlotData(xv, yv []reflect.Value) (x []string, y []float64) { + for i := range xv { + if xv[i].IsValid() && xv[i].CanInterface() { + x = append(x, fmt.Sprint(xv[i].Interface())) } } - for _, yv := range yvv { - for i := range yv { - if yv[i].IsValid() && yv[i].CanInterface() { - yvi, ok := yv[i].Interface().(float64) - if ok { - y = append(y, yvi) - } + for i := range yv { + if yv[i].IsValid() && yv[i].CanInterface() { + yvi, ok := yv[i].Interface().(float64) + if ok { + y = append(y, yvi) } } } return } -func barPlot(xvv, yvv [][]reflect.Value, c draw.Canvas) string { - groups, y := barPlotData(xvv, yvv) +func barPlot(xv, yv []reflect.Value, c draw.Canvas) string { + groups, y := barPlotData(xv, yv) chart := plot.NewBarChart(c) - data := new(plot.DataTable) + data := new(data.Table) + if len(y) == 0 { + log.Fatal("no valid y values given") + } if len(groups) != len(y) { for i := range y { data.AddColumn(fmt.Sprint(i)) diff --git a/cmd/jp/hist.go b/cmd/jp/hist.go new file mode 100644 index 0000000..77c04e6 --- /dev/null +++ b/cmd/jp/hist.go @@ -0,0 +1,50 @@ +package main + +import ( + "log" + "reflect" + + "github.com/sgreben/jp/pkg/data" + "github.com/sgreben/jp/pkg/draw" + "github.com/sgreben/jp/pkg/plot" +) + +func histogramData(xv []reflect.Value, nbins uint) (groups []string, counts []float64) { + var x []float64 + for i := range xv { + if xv[i].IsValid() && xv[i].CanInterface() { + xvi, ok := xv[i].Interface().(float64) + if ok { + x = append(x, xvi) + } + } + } + if len(x) == 0 { + log.Fatal("no valid x values given") + } + bins := data.NewBins(x) + bins.Number = int(nbins) + if nbins == 0 { + bins.ChooseSturges() + } + hist := data.Histogram(x, bins) + groups = make([]string, len(hist)) + counts = make([]float64, len(hist)) + for i, b := range hist { + groups[i] = b.String() + counts[i] = float64(b.Count) + } + return +} + +func histogram(xv []reflect.Value, c draw.Canvas, nbins uint) string { + groups, counts := histogramData(xv, nbins) + chart := plot.NewBarChart(c) + chart.BarPaddingX = 0 + data := new(data.Table) + for _, g := range groups { + data.AddColumn(g) + } + data.AddRow(counts...) + return chart.Draw(data) +} diff --git a/cmd/jp/line.go b/cmd/jp/line.go index caca49f..8054f87 100644 --- a/cmd/jp/line.go +++ b/cmd/jp/line.go @@ -1,47 +1,48 @@ package main import ( + "log" "reflect" + "github.com/sgreben/jp/pkg/data" "github.com/sgreben/jp/pkg/draw" "github.com/sgreben/jp/pkg/plot" ) -func linePlotData(xvv, yvv [][]reflect.Value) (x, y []float64) { - for _, xv := range xvv { - for i := range xv { - if xv[i].IsValid() && xv[i].CanInterface() { - xvi, ok := xv[i].Interface().(float64) - if ok { - x = append(x, xvi) - } +func linePlotData(xv, yv []reflect.Value) (x, y []float64) { + for i := range xv { + if xv[i].IsValid() && xv[i].CanInterface() { + xvi, ok := xv[i].Interface().(float64) + if ok { + x = append(x, xvi) } } } - for _, yv := range yvv { - for i := range yv { - if yv[i].IsValid() && yv[i].CanInterface() { - yvi, ok := yv[i].Interface().(float64) - if ok { - y = append(y, yvi) - } + for i := range yv { + if yv[i].IsValid() && yv[i].CanInterface() { + yvi, ok := yv[i].Interface().(float64) + if ok { + y = append(y, yvi) } } } return } -func linePlot(xvv, yvv [][]reflect.Value, c draw.Canvas) string { - x, y := linePlotData(xvv, yvv) +func linePlot(xv, yv []reflect.Value, c draw.Canvas) string { + x, y := linePlotData(xv, yv) chart := plot.NewLineChart(c) - data := new(plot.DataTable) + data := new(data.Table) data.AddColumn("x") data.AddColumn("y") n := len(x) if len(y) > n { n = len(y) } + if len(y) == 0 { + log.Fatal("no valid y values given") + } // If no valid xs are given, use the indices as x values. if len(x) == 0 { x = make([]float64, len(y)) diff --git a/cmd/jp/main.go b/cmd/jp/main.go index 62f3db0..173ac27 100644 --- a/cmd/jp/main.go +++ b/cmd/jp/main.go @@ -23,6 +23,7 @@ type configuration struct { PlotType enumVar CanvasType enumVar InputType enumVar + HistBins uint } const ( @@ -51,6 +52,7 @@ var config = configuration{ plotTypeLine, plotTypeBar, plotTypeScatter, + plotTypeHist, }, }, CanvasType: enumVar{ @@ -86,8 +88,10 @@ func init() { flag.StringVar(&config.XY, "xy", "", "x,y value pairs (JSONPath expression). Overrides -x and -y if given.") flag.IntVar(&config.Box.Width, "width", 0, "Plot width (default 0 (auto))") flag.IntVar(&config.Box.Height, "height", 0, "Plot height (default 0 (auto))") + flag.UintVar(&config.HistBins, "bins", 0, "Number of histogram bins (default 0 (auto))") flag.Parse() log.SetOutput(os.Stderr) + log.SetFlags(log.LstdFlags | log.Lshortfile) var err error xPattern = jsonpath.New("x") @@ -143,12 +147,12 @@ func main() { } in = parseRows(rows) } - var x, y [][]reflect.Value + var x, y []reflect.Value if xyPattern != nil { x, y = split(match(in, xyPattern)) } else { - x = match(in, xPattern) - y = match(in, yPattern) + x = flatten(match(in, xPattern)) + y = flatten(match(in, yPattern)) } buffer := draw.NewBuffer(config.Box) var p draw.Pixels @@ -162,7 +166,6 @@ func main() { } p.Clear() c := draw.Canvas{Pixels: p} - fmt.Println() switch config.PlotType.Value { case plotTypeLine: fmt.Println(linePlot(x, y, c)) @@ -170,5 +173,7 @@ func main() { fmt.Println(scatterPlot(x, y, c)) case plotTypeBar: fmt.Println(barPlot(x, y, c)) + case plotTypeHist: + fmt.Println(histogram(x, c, config.HistBins)) } } diff --git a/cmd/jp/scatter.go b/cmd/jp/scatter.go index 9df8b4a..2fdf0e6 100644 --- a/cmd/jp/scatter.go +++ b/cmd/jp/scatter.go @@ -3,39 +3,35 @@ package main import ( "reflect" + "github.com/sgreben/jp/pkg/data" "github.com/sgreben/jp/pkg/draw" - "github.com/sgreben/jp/pkg/plot" ) -func scatterPlotData(xvv, yvv [][]reflect.Value) (x, y []float64) { - for _, xv := range xvv { - for i := range xv { - if xv[i].IsValid() && xv[i].CanInterface() { - xvi, ok := xv[i].Interface().(float64) - if ok { - x = append(x, xvi) - } +func scatterPlotData(xv, yv []reflect.Value) (x, y []float64) { + for i := range xv { + if xv[i].IsValid() && xv[i].CanInterface() { + xvi, ok := xv[i].Interface().(float64) + if ok { + x = append(x, xvi) } } } - for _, yv := range yvv { - for i := range yv { - if yv[i].IsValid() && yv[i].CanInterface() { - yvi, ok := yv[i].Interface().(float64) - if ok { - y = append(y, yvi) - } + for i := range yv { + if yv[i].IsValid() && yv[i].CanInterface() { + yvi, ok := yv[i].Interface().(float64) + if ok { + y = append(y, yvi) } } } return } -func scatterPlot(xvv, yvv [][]reflect.Value, c draw.Canvas) string { - x, y := scatterPlotData(xvv, yvv) +func scatterPlot(xv, yv []reflect.Value, c draw.Canvas) string { + x, y := scatterPlotData(xv, yv) chart := plot.NewScatterChart(c) - data := new(plot.DataTable) + data := new(data.Table) data.AddColumn("x") data.AddColumn("y") n := len(x) diff --git a/cmd/jp/split.go b/cmd/jp/split.go index 4f36825..efe59c9 100644 --- a/cmd/jp/split.go +++ b/cmd/jp/split.go @@ -11,10 +11,10 @@ func flatten(in [][]reflect.Value) (out []reflect.Value) { return } -func split(in [][]reflect.Value) (x, y [][]reflect.Value) { +func split(in [][]reflect.Value) (x, y []reflect.Value) { flat := flatten(in) n := len(flat) - x = [][]reflect.Value{flat[:n/2]} - y = [][]reflect.Value{flat[n/2:]} + x = flat[:n/2] + y = flat[n/2:] return } diff --git a/pkg/data/histogram.go b/pkg/data/histogram.go new file mode 100644 index 0000000..06e5d95 --- /dev/null +++ b/pkg/data/histogram.go @@ -0,0 +1,108 @@ +package data + +import ( + "fmt" + "math" +) + +import "strconv" + +const maxDigits = 6 + +func ff(x float64) string { + minExact := strconv.FormatFloat(x, 'g', -1, 64) + fixed := strconv.FormatFloat(x, 'g', maxDigits, 64) + if len(minExact) < len(fixed) { + return minExact + } + return fixed +} + +type Bin struct { + LeftInclusive float64 + Right float64 + RightInclusive bool + Count uint64 +} + +func (b *Bin) String() string { + if b.RightInclusive { + return fmt.Sprintf("[%s,%s]", ff(b.LeftInclusive), ff(b.Right)) + } + return fmt.Sprintf("[%s,%s)", ff(b.LeftInclusive), ff(b.Right)) +} + +type Bins struct { + Number int + min, max float64 + numPoints int +} + +func (b *Bins) ChooseSqrt() { + b.Number = int(math.Sqrt(float64(b.numPoints))) +} + +func (b *Bins) ChooseSturges() { + b.Number = int(math.Ceil(math.Log2(float64(b.numPoints))) + 1) +} + +func (b *Bins) ChooseRice() { + b.Number = int(math.Ceil(2 * math.Pow(float64(b.numPoints), 1.0/3.0))) +} + +func NewBins(points []float64) *Bins { + bins := new(Bins) + bins.numPoints = len(points) + bins.Number = 5 + bins.min = math.Inf(1) + bins.max = math.Inf(-1) + for _, x := range points { + bins.min = math.Min(bins.min, x) + bins.max = math.Max(bins.max, x) + } + return bins +} + +func (b *Bins) left(i int) float64 { + return (b.max - b.min) / float64(b.Number) * float64(i) +} + +func (b *Bins) right(i int) float64 { + return b.left(i + 1) +} + +func (b *Bins) All() (out []Bin) { + if b.max == b.min { + b.Number = 1 + } + for i := 0; i < b.Number; i++ { + out = append(out, Bin{ + LeftInclusive: b.left(i), + Right: b.right(i), + }) + } + out[b.Number-1].RightInclusive = true + return +} + +func (b *Bins) Point(x float64) int { + if b.max == b.min { + return 0 + } + i := int((x - b.min) / (b.max - b.min) * float64(b.Number)) + if i >= b.Number { + i-- + } + return i +} + +func Histogram(points []float64, bins *Bins) (out []Bin) { + out = bins.All() + for _, b := range out { + b.Count = 0 + } + for _, x := range points { + out[bins.Point(x)].Count++ + } + return +} diff --git a/pkg/data/table.go b/pkg/data/table.go new file mode 100644 index 0000000..764ee23 --- /dev/null +++ b/pkg/data/table.go @@ -0,0 +1,14 @@ +package data + +type Table struct { + Columns []string + Rows [][]float64 +} + +func (d *Table) AddColumn(name string) { + d.Columns = append(d.Columns, name) +} + +func (d *Table) AddRow(elms ...float64) { + d.Rows = append(d.Rows, elms) +} diff --git a/pkg/plot/barchart.go b/pkg/plot/barchart.go index 6bcacd9..04a36be 100644 --- a/pkg/plot/barchart.go +++ b/pkg/plot/barchart.go @@ -4,6 +4,7 @@ import ( "bytes" "math" + "github.com/sgreben/jp/pkg/data" "github.com/sgreben/jp/pkg/draw" ) @@ -22,10 +23,10 @@ func NewBarChart(canvas draw.Canvas) *BarChart { } // Draw implements Chart -func (c *BarChart) Draw(data *DataTable) string { +func (c *BarChart) Draw(table *data.Table) string { minY := math.Inf(1) maxY := math.Inf(-1) - for _, row := range data.Rows { + for _, row := range table.Rows { for _, y := range row { if y < minY { minY = y @@ -35,12 +36,31 @@ func (c *BarChart) Draw(data *DataTable) string { } } } - paddingX := 4 + paddingX := 2 paddingY := 3 chartHeight := c.Size().Height - paddingY*c.RuneSize().Height chartWidth := c.Size().Width - 2*paddingX*c.RuneSize().Width + + labelsBelowBars := true + labelsRight := false + maxLabelLength := 0 + totalLabelLength := 0 + for _, group := range table.Columns { + totalLabelLength += len(group) + if len(group) > maxLabelLength { + maxLabelLength = len(group) + } + } + if totalLabelLength*c.RuneSize().Width > chartWidth { + labelsBelowBars = false + if len(table.Columns)*c.RuneSize().Height <= chartHeight { + labelsRight = true + chartWidth -= 3 + maxLabelLength*c.RuneSize().Width + } + } + scaleY := float64(chartHeight) / maxY - barPaddedWidth := chartWidth / len(data.Columns) + barPaddedWidth := chartWidth / len(table.Columns) barWidth := barPaddedWidth - (c.BarPaddingX * c.RuneSize().Width) if barPaddedWidth < c.RuneSize().Width { barPaddedWidth = c.RuneSize().Width @@ -51,11 +71,11 @@ func (c *BarChart) Draw(data *DataTable) string { scaleY = float64(chartHeight) / maxY - for i, group := range data.Columns { + for i, group := range table.Columns { barLeft := paddingX*c.RuneSize().Width + barPaddedWidth*i barRight := barLeft + barWidth - y := data.Rows[0][i] + y := table.Rows[0][i] barHeight := y * scaleY barBottom := (paddingY - 1) * c.RuneSize().Height barTop := barBottom + int(barHeight) @@ -66,20 +86,29 @@ func (c *BarChart) Draw(data *DataTable) string { } } - // Group label barMiddle := int(math.Floor(float64(barLeft+barRight) / float64(2*c.RuneSize().Width))) - c.GetBuffer().WriteCenter(0, barMiddle, []rune(group)) + + // Group label + if labelsBelowBars { + c.GetBuffer().WriteCenter(0, barMiddle, []rune(group)) + } else { + c.GetBuffer().WriteCenter(0, barMiddle, []rune(Fi(i))) + } // Count label countLabelY := int(math.Ceil(float64(barTop)/float64(c.RuneSize().Height))) * c.RuneSize().Height - if countLabelY <= barBottom && y > 0 { c.GetBuffer().SetRow(barTop/c.RuneSize().Height, barLeft/c.RuneSize().Width, barRight/c.RuneSize().Width, '▁') countLabelY = 3 * c.RuneSize().Height } - c.GetBuffer().WriteCenter(countLabelY/c.RuneSize().Height, barMiddle, Ff(y)) } + if labelsRight { + for i, group := range table.Columns { + c.GetBuffer().WriteRight(c.GetBuffer().Height-i, paddingX+1+chartWidth/c.RuneSize().Width, []rune(Fi(i))) + c.GetBuffer().WriteRight(c.GetBuffer().Height-i, paddingX+4+chartWidth/c.RuneSize().Width, []rune(group)) + } + } b := bytes.NewBuffer(nil) c.GetBuffer().Render(b) diff --git a/pkg/plot/datatable.go b/pkg/plot/datatable.go deleted file mode 100644 index 15cbff4..0000000 --- a/pkg/plot/datatable.go +++ /dev/null @@ -1,14 +0,0 @@ -package plot - -type DataTable struct { - Columns []string - Rows [][]float64 -} - -func (d *DataTable) AddColumn(name string) { - d.Columns = append(d.Columns, name) -} - -func (d *DataTable) AddRow(elms ...float64) { - d.Rows = append(d.Rows, elms) -} diff --git a/pkg/plot/format.go b/pkg/plot/format.go index f1175dd..03d19f0 100644 --- a/pkg/plot/format.go +++ b/pkg/plot/format.go @@ -13,3 +13,8 @@ func Ff(x float64) []rune { } return []rune(fixed) } + +// Fi formats an int +func Fi(x int) []rune { + return []rune(strconv.FormatInt(int64(x), 10)) +} diff --git a/pkg/plot/linechart.go b/pkg/plot/linechart.go index 25468b9..cfddd0e 100644 --- a/pkg/plot/linechart.go +++ b/pkg/plot/linechart.go @@ -4,6 +4,7 @@ import ( "bytes" "math" + "github.com/sgreben/jp/pkg/data" "github.com/sgreben/jp/pkg/draw" ) @@ -29,11 +30,11 @@ func (c *LineChart) drawAxes(paddingX, paddingY int, minX, maxX, minY, maxY floa } // Draw implements Chart -func (c *LineChart) Draw(data *DataTable) string { +func (c *LineChart) Draw(table *data.Table) string { var scaleY, scaleX float64 var prevX, prevY int - minX, maxX, minY, maxY := minMax(data) + minX, maxX, minY, maxY := minMax(table) minLabelWidth := len(Ff(minY)) maxLabelWidth := len(Ff(maxY)) @@ -48,7 +49,7 @@ func (c *LineChart) Draw(data *DataTable) string { scaleY = float64(chartHeight) / (maxY - minY) first := true - for _, point := range data.Rows { + for _, point := range table.Rows { if len(point) < 2 { continue } @@ -83,11 +84,11 @@ func roundUpToPercentOfRange(x, d float64) float64 { return math.Ceil((x*105)/d) * d / 100 } -func minMax(data *DataTable) (minX, maxX, minY, maxY float64) { +func minMax(table *data.Table) (minX, maxX, minY, maxY float64) { minX, minY = math.Inf(1), math.Inf(1) maxX, maxY = math.Inf(-1), math.Inf(-1) - for _, r := range data.Rows { + for _, r := range table.Rows { if len(r) < 2 { continue } diff --git a/pkg/plot/scatterchart.go b/pkg/plot/scatterchart.go index 038b814..3ea7187 100644 --- a/pkg/plot/scatterchart.go +++ b/pkg/plot/scatterchart.go @@ -3,6 +3,7 @@ package plot import ( "bytes" + "github.com/sgreben/jp/pkg/data" "github.com/sgreben/jp/pkg/draw" ) @@ -28,10 +29,10 @@ func (c *ScatterChart) drawAxes(paddingX, paddingY int, minX, maxX, minY, maxY f } // Draw implements Chart -func (c *ScatterChart) Draw(data *DataTable) string { +func (c *ScatterChart) Draw(table *data.Table) string { var scaleY, scaleX float64 - minX, maxX, minY, maxY := minMax(data) + minX, maxX, minY, maxY := minMax(table) minLabelWidth := len(Ff(minY)) maxLabelWidth := len(Ff(maxY)) @@ -45,7 +46,7 @@ func (c *ScatterChart) Draw(data *DataTable) string { scaleX = float64(chartWidth) / (maxX - minX) scaleY = float64(chartHeight) / (maxY - minY) - for _, point := range data.Rows { + for _, point := range table.Rows { if len(point) < 2 { continue }