Repository: saulpw/visidata

Issue:
[graph] zoom-in and zoom-out misaligned
### Discussed in https://github.com/saulpw/visidata/discussions/2106

Zooming in and then back out of a graph should take you back to your original starting point. Now, it goes slightly off until eventually the plot disappears off the screen.

## Expected Behavior

This is the expected behavior when you zoom-in and out on a graph with your mouse-wheel:

![correct](https://github.com/saulpw/visidata/assets/7489659/53a7f0a8-451d-4606-851d-b7c67e5cd6da)

And this is the develop behavior:

![incorrect](https://github.com/saulpw/visidata/assets/7489659/3df773cf-184a-4a46-89f7-1c3628927594)



Discussion:
Zooming in with the mouse or keyboard triggers `incrZoom`, it might behave more predictably if the zoom level were modified linearly (`+`) rather than geometrically (`*`):

https://github.com/isosphere/visidata/blob/216bb5aab865375cc9017e56d85f4d124dab6813/visidata/canvas.py#L523-L527

Combined with floating point imprecision that might cause drift; a `Decimal` type for the zoom properties could help with that. 

I'm having trouble reproducing the behaviour or I'd try it, I haven't used plots much yet.  
@isosphere This was working fine before with geometric zoom.  I tried linear zoom and it didn't 'feel' right.  This isn't a precision issue; likely just an off-by-one error from a change we made to the canvas in ~august or so.
@anjakefala
I am leaving a comment here so you can assign this to me, as you asked in the Discussion related to this.

I've figured out what's happening here. `Graph` and`InvertedCanvas` use a different coordinate system from Canvas. Their y-axis grows in the opposite direction and starts from a different origin point. `zoomin/out-mouse` incorrectly rely on `Canvas.fixPoint()`, which uses the y-coordinate system for `Canvas`. `InvertedCanvas` needs its own version of `fixPoint()`. I'm testing a fix now.

If there are more such bugs in `graph.py` discovered in the future, the fix would follow this general rule: look for functions being called in `graph.py` that are in `Canvas` that involve y-coordinate calculations. Override them in `InvertedCanvas` and modify them to use inverted y-coordinates.

Resolving patch:
diff --git a/visidata/canvas.py b/visidata/canvas.py
--- a/visidata/canvas.py
+++ b/visidata/canvas.py
@@ -785,7 +785,7 @@ def deleteSourceRows(self, rows):
 Canvas.bindkey('BUTTON3_TRIPLE_CLICKED', 'move-canvas')
 
 Canvas.addCommand('ScrollUp', 'zoomin-mouse', 'cm=canvasMouse; incrZoom(1.0/options.disp_zoom_incr) if cm else fail("cannot zoom in on unplotted canvas"); fixPoint(plotterMouse, cm)', 'zoom in with scroll wheel')
-Canvas.addCommand('ScrollDown', 'zoomout-mouse', 'cm=canvasMouse; incrZoom(options.disp_zoom_incr) if cm else fail("cannot zoom in on unplotted canvas"); fixPoint(plotterMouse, cm)', 'zoom out with scroll wheel')
+Canvas.addCommand('ScrollDown', 'zoomout-mouse', 'cm=canvasMouse; incrZoom(options.disp_zoom_incr) if cm else fail("cannot zoom out on unplotted canvas"); fixPoint(plotterMouse, cm)', 'zoom out with scroll wheel')
 
 Canvas.addCommand('s', 'select-cursor', 'source.select(list(rowsWithin(plotterCursorBox)))', 'select rows on source sheet contained within canvas cursor')
 Canvas.addCommand('t', 'stoggle-cursor', 'source.toggle(list(rowsWithin(plotterCursorBox)))', 'toggle selection of rows on source sheet contained within canvas cursor')
diff --git a/visidata/graph.py b/visidata/graph.py
--- a/visidata/graph.py
+++ b/visidata/graph.py
@@ -17,6 +17,12 @@ class InvertedCanvas(Canvas):
     def render_async(self):
         self.plot_elements(invert_y=True)
 
+    def fixPoint(self, plotterPoint, canvasPoint):
+        'adjust visibleBox.xymin so that canvasPoint is plotted at plotterPoint'
+        self.visibleBox.xmin = canvasPoint.x - self.canvasW(plotterPoint.x-self.plotviewBox.xmin)
+        self.visibleBox.ymin = canvasPoint.y - self.canvasH(self.plotviewBox.ymax-plotterPoint.y)
+        self.refresh()
+
     def zoomTo(self, bbox):
         super().zoomTo(bbox)
         self.fixPoint(Point(self.plotviewBox.xmin, self.plotviewBox.ymax),


Test patch: