diff --git a/demos/demo/main.go b/demos/demo/main.go index 32c1f6b..6284cce 100644 --- a/demos/demo/main.go +++ b/demos/demo/main.go @@ -204,20 +204,9 @@ func main() { } moveSinData := func(data [][]float64) [][]float64 { - n := 220 newData := make([][]float64, 2) - newData[0] = make([]float64, n) - newData[1] = make([]float64, n) - - for i := 0; i < n; i++ { - if i+1 < len(data[0]) { - newData[0][i] = data[0][i+1] - } - if i+1 < len(data[1]) { - newData[1][i] = data[1][i+1] - } - } - + newData[0] = rotate(data[0], -1) + newData[1] = rotate(data[1], -1) return newData } @@ -330,3 +319,23 @@ func newBarChart() *tvxwidgets.BarChart { return barGraph } + +// Source: https://stackoverflow.com/questions/50833673/rotate-array-in-go/79079760#79079760 +// rotate rotates the given slice by k positions to the left or right. +func rotate[T any](slice []T, k int) []T { + if len(slice) == 0 { + return slice + } + + var r int + if k > 0 { + r = len(slice) - k%len(slice) + } else { + kAbs := int(math.Abs(float64(k))) + r = kAbs % len(slice) + } + + slice = append(slice[r:], slice[:r]...) + + return slice +} diff --git a/demos/plot_xaxis_labels/README.md b/demos/plot_xaxis_labels/README.md new file mode 100644 index 0000000..4a14e6c --- /dev/null +++ b/demos/plot_xaxis_labels/README.md @@ -0,0 +1 @@ +![Screenshot](screenshot.png) diff --git a/demos/plot_xaxis_labels/main.go b/demos/plot_xaxis_labels/main.go new file mode 100644 index 0000000..21ee5a0 --- /dev/null +++ b/demos/plot_xaxis_labels/main.go @@ -0,0 +1,133 @@ +package main + +import ( + "fmt" + "github.com/gdamore/tcell/v2" + "github.com/navidys/tvxwidgets" + "github.com/rivo/tview" + "math" + "time" +) + +func main() { + + app := tview.NewApplication() + + // >>> Data Function <<< + // With these values, the curve will start with a value of 0 and reach a + // high point of 2 at x = 3.14 (Pi) and then return to 0 at x = 6.28 (2*Pi). + + // Play around with these values to get a feel for how they affect the curve + // and how you might adapt this code to plot other functions. + + period := 2 * math.Pi + horizontalStretchFactor := 1.0 + verticalStretchFactor := 1.0 + xOffset := 0.0 + yOffset := 0.0 + + // >>> Graph View/Camera Controls <<< + // These values influence which part of the curve is shown in + // what "zoom level". + + xAxisZoomFactor := 3.0 + yAxisZoomFactor := 1.0 + xAxisShift := 0.0 + yAxisShift := 0.0 + + // xFunc1 defines the x values that should be used for each vertical "slot" in the graph. + xFunc1 := func(i int) float64 { + return (float64(i) / xAxisZoomFactor) + xAxisShift + } + // yFunc1 defines the y values that result from a given input value x (this is the actual function). + yFunc1 := func(x float64) float64 { + return (math.Sin((x+xOffset)/horizontalStretchFactor) + yOffset) * verticalStretchFactor + } + + // xLabelFunc1 defines a label for each vertical "slot". Which labels are shown is determined automatically + // based on the available space. + xLabelFunc1 := func(i int) string { + xVal := xFunc1(i) + labelVal := xVal + label := fmt.Sprintf("%.1f", labelVal) + return label + } + + // computeDataArray computes the y values for n vertical slots based on the definitions above. + computeDataArray := func() [][]float64 { + n := 150 + data := make([][]float64, 1) + data[0] = make([]float64, n) + for i := 0; i < n; i++ { + xVal := xFunc1(i) + yVal := yFunc1(xVal) + data[0][i] = yVal + } + + return data + } + + data := computeDataArray() + + bmLineChart := tvxwidgets.NewPlot() + bmLineChart.SetBorder(true) + bmLineChart.SetTitle("line chart (braille mode)") + bmLineChart.SetLineColor([]tcell.Color{ + tcell.ColorSteelBlue, + tcell.ColorGreen, + }) + bmLineChart.SetMarker(tvxwidgets.PlotMarkerBraille) + bmLineChart.SetXAxisLabelFunc(xLabelFunc1) + bmLineChart.SetYAxisAutoScaleMin(false) + bmLineChart.SetYAxisAutoScaleMax(false) + bmLineChart.SetYRange( + (-1+yOffset+yAxisShift)/yAxisZoomFactor, + (1+yOffset+yAxisShift)/yAxisZoomFactor, + ) + bmLineChart.SetData(data) + + firstRow := tview.NewFlex().SetDirection(tview.FlexColumn) + firstRow.AddItem(bmLineChart, 0, 1, false) + firstRow.SetRect(0, 0, 100, 15) + + layout := tview.NewFlex().SetDirection(tview.FlexRow) + layout.AddItem(firstRow, 0, 1, false) + layout.SetRect(0, 0, 100, 30) + + animate := true + + rotateDataContinuously := func() { + tick := time.NewTicker(100 * time.Millisecond) + go func() { + initialxAxisShift := xAxisShift + for { + select { + case <-tick.C: + if !animate { + continue + } + + xAxisShift = xAxisShift + 0.1 + if xAxisShift >= initialxAxisShift+period*4 { + xAxisShift = initialxAxisShift + } + data = computeDataArray() + bmLineChart.SetData(data) + + app.Draw() + } + } + }() + } + + go rotateDataContinuously() + + if err := app.SetRoot(layout, false).EnableMouse(true).SetMouseCapture(func(event *tcell.EventMouse, action tview.MouseAction) (*tcell.EventMouse, tview.MouseAction) { + if action == tview.MouseLeftClick { + animate = !animate + } + return event, action + }).Run(); err != nil { + panic(err) + } +} diff --git a/demos/plot_xaxis_labels/screenshot.png b/demos/plot_xaxis_labels/screenshot.png new file mode 100644 index 0000000..ce1b4a5 Binary files /dev/null and b/demos/plot_xaxis_labels/screenshot.png differ diff --git a/plot.go b/plot.go index 26c9faa..01b9606 100644 --- a/plot.go +++ b/plot.go @@ -5,6 +5,7 @@ import ( "image" "math" "strconv" + "strings" "sync" "github.com/gdamore/tcell/v2" @@ -41,6 +42,8 @@ const ( plotXAxisLabelsHeight = 1 plotXAxisLabelsGap = 2 plotYAxisLabelsGap = 1 + + gapRune = " " ) type brailleCell struct { @@ -64,6 +67,7 @@ type Plot struct { axesLabelColor tcell.Color drawAxes bool drawXAxisLabel bool + xAxisLabelFunc func(int) string drawYAxisLabel bool yAxisLabelDataType PlotYAxisLabelDataType yAxisAutoScaleMin bool @@ -83,6 +87,7 @@ func NewPlot() *Plot { axesLabelColor: tcell.ColorDimGray, drawAxes: true, drawXAxisLabel: true, + xAxisLabelFunc: strconv.Itoa, drawYAxisLabel: true, yAxisLabelDataType: PlotYAxisLabelDataFloat, yAxisAutoScaleMin: false, @@ -152,6 +157,11 @@ func (plot *Plot) SetDrawXAxisLabel(draw bool) { plot.drawXAxisLabel = draw } +// SetXAxisLabelFunc sets x axis label function. +func (plot *Plot) SetXAxisLabelFunc(f func(int) string) { + plot.xAxisLabelFunc = f +} + // SetDrawYAxisLabel set true in order to draw y axis label to screen. func (plot *Plot) SetDrawYAxisLabel(draw bool) { plot.drawYAxisLabel = draw @@ -262,34 +272,102 @@ func (plot *Plot) drawAxesToScreen(screen tcell.Screen) { tview.BoxDrawingsLightUpAndRight, axesStyle) if plot.drawXAxisLabel { - plot.drawXAxisLabelToScreen(screen, plotYAxisLabelsWidth, x, y, width, height) + plot.drawXAxisLabelsToScreen(screen, plotYAxisLabelsWidth, x, y, width, height) } if plot.drawYAxisLabel { - plot.drawYAxisLabelToScreen(screen, plotYAxisLabelsWidth, x, y, height) + plot.drawYAxisLabelsToScreen(screen, plotYAxisLabelsWidth, x, y, height) } } -func (plot *Plot) drawXAxisLabelToScreen( +//nolint:funlen,cyclop +func (plot *Plot) drawXAxisLabelsToScreen( screen tcell.Screen, plotYAxisLabelsWidth int, x int, y int, width int, height int, ) { - tview.Print(screen, "0", - x+plotYAxisLabelsWidth, - y+height-plotXAxisLabelsHeight, - 1, - tview.AlignLeft, plot.axesLabelColor) + xAxisAreaStartX := x + plotYAxisLabelsWidth + 1 + xAxisAreaEndX := x + width + xAxisAvailableWidth := xAxisAreaEndX - xAxisAreaStartX - for labelX := x + plotYAxisLabelsWidth + - (plotXAxisLabelsGap)*plotHorizontalScale + 1; labelX < x+width-1; { - label := strconv.Itoa((labelX-(x+plotYAxisLabelsWidth)-1)/(plotHorizontalScale) + 1) + labelMap := map[int]string{} + labelStartMap := map[int]int{} - tview.Print(screen, label, labelX, y+height-plotXAxisLabelsHeight, width, tview.AlignLeft, plot.axesLabelColor) + maxDataPoints := 0 + for _, d := range plot.data { + maxDataPoints = max(maxDataPoints, len(d)) + } + + // determine the width needed for the largest label + maxXAxisLabelWidth := 0 - labelX += (len(label) + plotXAxisLabelsGap) * plotHorizontalScale + for _, d := range plot.data { + for i := range d { + label := plot.xAxisLabelFunc(i) + labelMap[i] = label + maxXAxisLabelWidth = max(maxXAxisLabelWidth, len(label)) + } } + + // determine the start position for each label, if they were + // to be centered below the data point. + // Note: not all of these labels will be printed, as they would + // overlap with each other + for i, label := range labelMap { + expectedLabelWidth := len(label) + if i == 0 { + expectedLabelWidth += plotXAxisLabelsGap / 2 //nolint:gomnd + } else { + expectedLabelWidth += plotXAxisLabelsGap + } + + currentLabelStart := i - int(math.Round(float64(expectedLabelWidth)/2)) //nolint:gomnd + labelStartMap[i] = currentLabelStart + } + + // print the labels, skipping those that would overlap, + // stopping when there is no more space + lastUsedLabelEnd := math.MinInt + initialOffset := xAxisAreaStartX + + for i := 0; i < maxDataPoints; i++ { + labelStart := labelStartMap[i] + if labelStart < lastUsedLabelEnd { + // the label would overlap with the previous label + continue + } + + rawLabel := labelMap[i] + labelWithGap := rawLabel + + if i == 0 { + labelWithGap += strings.Repeat(gapRune, plotXAxisLabelsGap/2) //nolint:gomnd + } else { + labelWithGap = strings.Repeat(gapRune, plotXAxisLabelsGap/2) + labelWithGap + strings.Repeat(gapRune, plotXAxisLabelsGap/2) //nolint:lll,gomnd + } + + expectedLabelWidth := len(labelWithGap) + remainingWidth := xAxisAvailableWidth - labelStart + + if expectedLabelWidth > remainingWidth { + // the label would be too long to fit in the remaining space + if expectedLabelWidth-1 <= remainingWidth { + // if we omit the last gap, it fits, so we draw that before stopping + labelWithoutGap := labelWithGap[:len(labelWithGap)-1] + plot.printXAxisLabel(screen, labelWithoutGap, initialOffset+labelStart, y+height-plotXAxisLabelsHeight) + } + + break + } + + lastUsedLabelEnd = labelStart + expectedLabelWidth + plot.printXAxisLabel(screen, labelWithGap, initialOffset+labelStart, y+height-plotXAxisLabelsHeight) + } +} + +func (plot *Plot) printXAxisLabel(screen tcell.Screen, label string, x, y int) { + tview.Print(screen, label, x, y, len(label), tview.AlignLeft, plot.axesLabelColor) } -func (plot *Plot) drawYAxisLabelToScreen(screen tcell.Screen, plotYAxisLabelsWidth int, x int, y int, height int) { +func (plot *Plot) drawYAxisLabelsToScreen(screen tcell.Screen, plotYAxisLabelsWidth int, x int, y int, height int) { verticalOffset := plot.minVal verticalScale := (plot.maxVal - plot.minVal) / float64(height-plotXAxisLabelsHeight-1) previousLabel := ""