has2k1 / plotnine

A Grammar of Graphics for Python
https://plotnine.org
MIT License
4k stars 213 forks source link

Problems with coordinate mapping computation in Shiny #738

Closed wch closed 8 months ago

wch commented 9 months ago

Shiny is having a problem with calculating coordinate information with the release version of Plotnine.

I'll demonstrate with this app from the shinylive examples: https://shinylive.io/py/examples/#basic-plot-interaction

With this screenshot, the cursor is at (4, 25), and the x and y coordinates are detected to be right on, at (4.0, 25.0). Note that this is with shinylive, which uses a custom build of plotnine that avoids the problem. (https://github.com/wch/plotnine/tree/fix-dims)

image

If I save the files from that app and run it with regular Python (not shinylive) using shiny 0.6.1.1 and plotnine 0.12.4 installed from PyPI, it demonstrates the problem -- the detected coordinates are slightly off, at (4.27, 25.9):

image

The code in Shiny that extracts the coordmap information is here. It basically finds the mapping between the pixel coordinate space and the data coordinate space. I believe that in the release version of plotnine, the detection of the pixel coordinate space is not correct.

https://github.com/posit-dev/py-shiny/blob/53c46fd6f71c5b19def29d1e94cae997bf93c5e0/shiny/render/_coordmap.py#L128-L200

has2k1 commented 9 months ago

The problem is that for plotnine the coordinate information is calculated before the layout engine has run. Thereafter, when the plot is drawn and the layout engine is run, the actual coordinate information is different.

Currently, coordinate mapping works for matplotlib because the layout engine is run prior to calculating the coordinate information. Though, there is a problem with plt.tight_layout(); it overrides the users layout (if they have set one) even if that layout compatible with the coordinate mapping calculations.

Assuming matplotlib >= 3.6.0, there are two options:

1. To solve it in plotnine

We could execute the layout engine

PlotnineLayoutEngine(plot_object).execute(figure)

instead of setting it:

figure.set_layout_engine(PlotnineLayoutEngine(plot_object))

which currently leaves matplotlib to execute it at drawing time and shiny doing mapping stuff before that.

2. To solve it in shiny

We would have to execute the layout engine any where before transforming the limits to pixel coordinates.

That would be:

import matplotlib.pyplot as plt  # pyright: ignore[reportUnusedImport] # noqa: F401
import warnings

layout_engine = fig.get_layout_engine()
if layout_engine:
    if layout_engine.adjust_compatible:
        layout_engine.execute(fig)
    else:
        with warnings.catch_warnings():
            warnings.filterwarnings(
                action="ignore",
                category=UserWarning,
                message="The figure layout has changed to tight",
            )        
            warnings.warn(
                f"{type(layout_engine)} layout engine is not compatible with shiny",
                ...
            )
            plt.tight_layout()  # pyright: ignore[reportUnknownMemberType]
else:
    plt.tight_layout()

This would respect the users layout engine when it is compatible and tight_layout otherwise.

The 2nd seems more robust, it would mean the layout engine runs twice although this can be remedied.