diff --git a/.gitignore b/.gitignore index c92265d..05a3908 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ result_images .pytest_cache dist *.egg-info +docs/_build/ +docs/api/generated +docs/examples/ \ No newline at end of file diff --git a/README.md b/README.md index fa5aa25..0a03f53 100644 --- a/README.md +++ b/README.md @@ -12,118 +12,16 @@ You can install matplotview using pip: pip install matplotview ``` -## Usage - -matplotview provides two methods, `view`, and `inset_zoom_axes`. The `view` -method accepts two `Axes`, and makes the first axes a view of the second. The -`inset_zoom_axes` method provides the same functionality as `Axes.inset_axes`, -but the returned inset axes is configured to be a view of the parent axes. - ## Examples -An example of two axes showing the same plot. -```python -from matplotview import view -import matplotlib.pyplot as plt -import numpy as np - -fig, (ax1, ax2) = plt.subplots(1, 2) - -# Plot a line, circle patch, some text, and an image... -ax1.plot([i for i in range(10)], "r") -ax1.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) -ax1.text(10, 10, "Hello World!", size=20) -ax1.imshow(np.random.rand(30, 30), origin="lower", cmap="Blues", alpha=0.5, - interpolation="nearest") - -# Turn axes 2 into a view of axes 1. -view(ax2, ax1) -# Modify the second axes data limits to match the first axes... -ax2.set_aspect(ax1.get_aspect()) -ax2.set_xlim(ax1.get_xlim()) -ax2.set_ylim(ax1.get_ylim()) - -fig.tight_layout() -fig.show() -``` -![First example plot results, two views of the same plot.](https://user-images.githubusercontent.com/47544550/149814592-dd815f95-c3ef-406d-bd7e-504859c836bf.png) - -An inset axes example. -```python -from matplotlib import cbook -import matplotlib.pyplot as plt -import numpy as np -from matplotview import inset_zoom_axes - -def get_demo_image(): - z = cbook.get_sample_data("axes_grid/bivariate_normal.npy", np_load=True) - # z is a numpy array of 15x15 - return z, (-3, 4, -4, 3) - -fig, ax = plt.subplots(figsize=[5, 4]) +Examples can be found in the example gallery: -# Make the data... -Z, extent = get_demo_image() -Z2 = np.zeros((150, 150)) -ny, nx = Z.shape -Z2[30:30+ny, 30:30+nx] = Z +[https://matplotview.readthedocs.io/en/latest/examples/index.html](https://matplotview.readthedocs.io/en/latest/examples/index.html) -ax.imshow(Z2, extent=extent, interpolation='nearest', origin="lower") +## Documentation -# Creates an inset axes with automatic view of the parent axes... -axins = inset_zoom_axes(ax, [0.5, 0.5, 0.47, 0.47]) -# Set limits to sub region of the original image -x1, x2, y1, y2 = -1.5, -0.9, -2.5, -1.9 -axins.set_xlim(x1, x2) -axins.set_ylim(y1, y2) -axins.set_xticklabels([]) -axins.set_yticklabels([]) +Additional documentation can be found at the link below: -ax.indicate_inset_zoom(axins, edgecolor="black") +[https://matplotview.readthedocs.io/en/latest/](https://matplotview.readthedocs.io/en/latest/) -fig.show() -``` -![Second example plot results, an inset axes showing a zoom view of an image.](https://user-images.githubusercontent.com/47544550/149814558-c2b1228d-2e5d-41be-86c0-f5dd01d42884.png) - -Because views support recursive drawing, they can be used to create -fractals also. -```python -import matplotlib.pyplot as plt -import matplotview as mpv -from matplotlib.patches import PathPatch -from matplotlib.path import Path -from matplotlib.transforms import Affine2D - -outside_color = "black" -inner_color = "white" - -t = Affine2D().scale(-0.5) - -outer_triangle = Path.unit_regular_polygon(3) -inner_triangle = t.transform_path(outer_triangle) -b = outer_triangle.get_extents() -fig, ax = plt.subplots(1) -ax.set_aspect(1) - -ax.add_patch(PathPatch(outer_triangle, fc=outside_color, ec=[0] * 4)) -ax.add_patch(PathPatch(inner_triangle, fc=inner_color, ec=[0] * 4)) -ax.set_xlim(b.x0, b.x1) -ax.set_ylim(b.y0, b.y1) - -ax_locs = [ - [0, 0, 0.5, 0.5], - [0.5, 0, 0.5, 0.5], - [0.25, 0.5, 0.5, 0.5] -] - -for loc in ax_locs: - inax = mpv.inset_zoom_axes(ax, loc, render_depth=6) - inax.set_xlim(b.x0, b.x1) - inax.set_ylim(b.y0, b.y1) - inax.axis("off") - inax.patch.set_visible(False) - -fig.show() -``` -![Third example plot results, a Sierpiński triangle](https://user-images.githubusercontent.com/47544550/150047401-e9364f0f-becd-45c5-a6f4-062118ce713f.png) \ No newline at end of file diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d4bb2cb --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/_static/gallery_mods.css b/docs/_static/gallery_mods.css new file mode 100644 index 0000000..7da4417 --- /dev/null +++ b/docs/_static/gallery_mods.css @@ -0,0 +1,20 @@ + +.sphx-glr-thumbcontainer[tooltip]:hover:after { + background: var(--sg-tooltip-background); + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; + color: var(--sg-tooltip-foreground); + content: ""; + opacity: 0.35; + padding: 10px; + z-index: 98; + width: 100%; + height: 100%; + position: absolute; + pointer-events: none; + top: 0; + box-sizing: border-box; + overflow: hidden; + backdrop-filter: blur(3px); +} \ No newline at end of file diff --git a/docs/api/index.rst b/docs/api/index.rst new file mode 100644 index 0000000..f66b580 --- /dev/null +++ b/docs/api/index.rst @@ -0,0 +1,13 @@ +API +=== + +The public facing functions of matplotview. + +.. autosummary:: + :toctree: generated + + matplotview.view + matplotview.stop_viewing + matplotview.inset_zoom_axes + + diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..462b3ea --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,47 @@ +from pathlib import Path +import sys + +# Add project root directory to python path... +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + + +project = 'matplotview' +copyright = '2022, Isaac Robinson' +author = 'Isaac Robinson' +release = '1.0.0' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.autosummary', + 'sphinx.ext.doctest', + 'sphinx.ext.intersphinx', + 'sphinx.ext.viewcode', + 'numpydoc', + 'matplotlib.sphinxext.mathmpl', + 'matplotlib.sphinxext.plot_directive', + 'sphinx_gallery.gen_gallery' +] + +templates_path = ['_templates'] +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + +from sphinx_gallery.sorting import FileNameSortKey + +sphinx_gallery_conf = { + "examples_dirs": "../examples", + "gallery_dirs": "examples", + "line_numbers": True, + "within_subsection_order": FileNameSortKey +} + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = 'alabaster' +html_static_path = ['_static'] +html_css_files = ['gallery_mods.css'] + +plot_include_source = True diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..0d6a0e0 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,22 @@ +.. matplotview documentation master file, created by + sphinx-quickstart on Sat Aug 13 19:55:28 2022. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Matplotview |release| Documentation +=================================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + installation + examples/index + api/index + + +Additional Links +================ + +* :ref:`genindex` +* :ref:`search` diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 0000000..4051030 --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,9 @@ +Installation +============ + +Matplotview can be installed using `pip `__: + +.. code-block:: bash + + pip install matplotview + diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..32bb245 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/examples/README.rst b/examples/README.rst new file mode 100644 index 0000000..43dcd19 --- /dev/null +++ b/examples/README.rst @@ -0,0 +1,7 @@ +Examples +======== + +Because of the way matplotview is designed, it can work with any Axes and projection +types, and works with all the default projection modes included in matplotlib. +The following examples showcase using matplotview in several different scenarios and +with different projections. \ No newline at end of file diff --git a/examples/plot_00_simplest_example.py b/examples/plot_00_simplest_example.py new file mode 100644 index 0000000..8075d2f --- /dev/null +++ b/examples/plot_00_simplest_example.py @@ -0,0 +1,23 @@ +""" +The Simplest View +================= + +The simplest example: We make a view of a line! Views can be created quickly +using :meth:`matplotview.view` . +""" + +from matplotview import view +import matplotlib.pyplot as plt + +fig, (ax1, ax2) = plt.subplots(1, 2) + +# Plot a line in the first axes. +ax1.plot([i for i in range(10)], "-o") + +# Create a view! Turn axes 2 into a view of axes 1. +view(ax2, ax1) +# Modify the second axes data limits so we get a slightly zoomed out view +ax2.set_xlim(-5, 15) +ax2.set_ylim(-5, 15) + +fig.show() \ No newline at end of file diff --git a/examples/plot_01_multiple_artist_view.py b/examples/plot_01_multiple_artist_view.py new file mode 100644 index 0000000..0f3a649 --- /dev/null +++ b/examples/plot_01_multiple_artist_view.py @@ -0,0 +1,29 @@ +""" +A View With Several Plot Elements +================================= + +A simple example with an assortment of plot elements. +""" + +from matplotview import view +import matplotlib.pyplot as plt +import numpy as np + +fig, (ax1, ax2) = plt.subplots(1, 2) + +# Plot a line, circle patch, some text, and an image... +ax1.plot([i for i in range(10)], "r") +ax1.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) +ax1.text(10, 10, "Hello World!", size=20) +ax1.imshow(np.random.rand(30, 30), origin="lower", cmap="Blues", alpha=0.5, + interpolation="nearest") + +# Turn axes 2 into a view of axes 1. +view(ax2, ax1) +# Modify the second axes data limits to match the first axes... +ax2.set_aspect(ax1.get_aspect()) +ax2.set_xlim(ax1.get_xlim()) +ax2.set_ylim(ax1.get_ylim()) + +fig.tight_layout() +fig.show() \ No newline at end of file diff --git a/examples/plot_02_simple_inset_view.py b/examples/plot_02_simple_inset_view.py new file mode 100644 index 0000000..0995e07 --- /dev/null +++ b/examples/plot_02_simple_inset_view.py @@ -0,0 +1,43 @@ +""" +Create An Inset Axes Without Plotting Twice +=========================================== + +:meth:`matplotview.inset_zoom_axes` can be utilized to create inset axes where we +don't have to plot the parent axes data twice. +""" + +from matplotlib import cbook +import matplotlib.pyplot as plt +import numpy as np +from matplotview import inset_zoom_axes + +def get_demo_image(): + z = cbook.get_sample_data("axes_grid/bivariate_normal.npy", np_load=True) + # z is a numpy array of 15x15 + return z, (-3, 4, -4, 3) + +fig, ax = plt.subplots() + +# Make the data... +Z, extent = get_demo_image() +Z2 = np.zeros((150, 150)) +ny, nx = Z.shape +Z2[30:30+ny, 30:30+nx] = Z + +ax.imshow(Z2, extent=extent, interpolation='nearest', origin="lower") + +# Creates an inset axes with automatic view of the parent axes... +axins = inset_zoom_axes(ax, [0.5, 0.5, 0.47, 0.47]) +# Set limits to sub region of the original image +x1, x2, y1, y2 = -1.5, -0.9, -2.5, -1.9 +axins.set_xlim(x1, x2) +axins.set_ylim(y1, y2) + +# Remove the tick labels from the inset axes +axins.set_xticklabels([]) +axins.set_yticklabels([]) + +# Draw the indicator or zoom lines. +ax.indicate_inset_zoom(axins, edgecolor="black") + +fig.show() \ No newline at end of file diff --git a/examples/plot_03_view_with_annotations.py b/examples/plot_03_view_with_annotations.py new file mode 100644 index 0000000..c89c5c2 --- /dev/null +++ b/examples/plot_03_view_with_annotations.py @@ -0,0 +1,48 @@ +""" +View With Annotations +===================== + +Matplotview's views are also regular matplotlib `Axes `_, +meaning they support regular plotting on top of their viewing capabilities, allowing +for annotations, as shown below. +""" + +# All the same as from the prior inset axes example... +from matplotlib import cbook +import matplotlib.pyplot as plt +import numpy as np +from matplotview import inset_zoom_axes + + +def get_demo_image(): + z = cbook.get_sample_data("axes_grid/bivariate_normal.npy", np_load=True) + return z, (-3, 4, -4, 3) + + +fig, ax = plt.subplots() + +Z, extent = get_demo_image() +Z2 = np.zeros((150, 150)) +ny, nx = Z.shape +Z2[30:30 + ny, 30:30 + nx] = Z + +ax.imshow(Z2, extent=extent, interpolation='nearest', origin="lower") + +axins = inset_zoom_axes(ax, [0.5, 0.5, 0.47, 0.47]) + +x1, x2, y1, y2 = -1.5, -0.9, -2.5, -1.9 +axins.set_xlim(x1, x2) +axins.set_ylim(y1, y2) + +# We'll annotate the 'interesting' spot in the view.... +axins.annotate( + "Interesting Feature", (-1.3, -2.25), (0.1, 0.1), + textcoords="axes fraction", arrowprops=dict(arrowstyle="->") +) + +axins.set_xticklabels([]) +axins.set_yticklabels([]) + +ax.indicate_inset_zoom(axins, edgecolor="black") + +fig.show() \ No newline at end of file diff --git a/examples/plot_04_sierpinski_triangle.py b/examples/plot_04_sierpinski_triangle.py new file mode 100644 index 0000000..18dae70 --- /dev/null +++ b/examples/plot_04_sierpinski_triangle.py @@ -0,0 +1,49 @@ +""" +Sierpiński Triangle With Recursive Views +======================================== + +Matplotview's views support recursive drawing of other views and themselves to a +configurable depth. This feature allows matplotview to be used to generate fractals, +such as a sierpiński triangle as shown in the following example. +""" + +import matplotlib.pyplot as plt +import matplotview as mpv +from matplotlib.patches import PathPatch +from matplotlib.path import Path +from matplotlib.transforms import Affine2D + +# We'll plot a white upside down triangle inside of black one, and then use +# 3 views to draw all the rest of the recursions of the sierpiński triangle. +outside_color = "black" +inner_color = "white" + +t = Affine2D().scale(-0.5) + +outer_triangle = Path.unit_regular_polygon(3) +inner_triangle = t.transform_path(outer_triangle) +b = outer_triangle.get_extents() + +fig, ax = plt.subplots(1) +ax.set_aspect(1) + +ax.add_patch(PathPatch(outer_triangle, fc=outside_color, ec=[0] * 4)) +ax.add_patch(PathPatch(inner_triangle, fc=inner_color, ec=[0] * 4)) +ax.set_xlim(b.x0, b.x1) +ax.set_ylim(b.y0, b.y1) + +ax_locs = [ + [0, 0, 0.5, 0.5], + [0.5, 0, 0.5, 0.5], + [0.25, 0.5, 0.5, 0.5] +] + +for loc in ax_locs: + # Here we limit the render depth to 6 levels in total for each zoom view.... + inax = mpv.inset_zoom_axes(ax, loc, render_depth=6) + inax.set_xlim(b.x0, b.x1) + inax.set_ylim(b.y0, b.y1) + inax.axis("off") + inax.patch.set_visible(False) + +fig.show() \ No newline at end of file diff --git a/examples/plot_05_3d_views.py b/examples/plot_05_3d_views.py new file mode 100644 index 0000000..d083998 --- /dev/null +++ b/examples/plot_05_3d_views.py @@ -0,0 +1,30 @@ +""" +Viewing 3D Axes +=============== + +Matplotview has built-in support for viewing 3D axes and plots. +""" +import matplotlib.pyplot as plt +import numpy as np +from matplotview import view + +X = Y = np.arange(-5, 5, 0.25) +X, Y = np.meshgrid(X, Y) +Z = np.sin(np.sqrt(X ** 2 + Y ** 2)) + +# Make some 3D plots... +fig, (ax1, ax2) = plt.subplots(1, 2, subplot_kw=dict(projection="3d")) + +# Plot our surface +ax1.plot_surface(X, Y, Z, cmap="plasma") + +# Axes 2 is now viewing axes 1. +view(ax2, ax1) + +# Update the limits, and set the elevation higher, so we get a better view of the inside of the surface. +ax2.view_init(elev=80) +ax2.set_xlim(-10, 10) +ax2.set_ylim(-10, 10) +ax2.set_zlim(-2, 2) + +fig.show() \ No newline at end of file diff --git a/examples/plot_06_polar_views.py b/examples/plot_06_polar_views.py new file mode 100644 index 0000000..1fcfe7b --- /dev/null +++ b/examples/plot_06_polar_views.py @@ -0,0 +1,30 @@ +""" +Viewing Polar Axes +================== + +Views also support viewing polar axes. +""" + +import numpy as np +import matplotlib.pyplot as plt +from matplotview import view + +# Create the data... +r = np.arange(0, 2, 0.01) +theta = 2 * np.pi * r + +fig, (ax, ax2) = plt.subplots(1, 2, subplot_kw=dict(projection='polar')) + +ax.plot(theta, r) +ax.set_rmax(2) +ax.set_rticks([0.5, 1, 1.5, 2]) # Less radial ticks +ax.set_rlabel_position(-22.5) # Move radial labels away from plotted line +# Include a grid +ax.grid(True) + +# ax2 is now zoomed in on ax. +view(ax2, ax) + +fig.tight_layout() + +fig.show() \ No newline at end of file diff --git a/examples/plot_07_geographic_viewing.py b/examples/plot_07_geographic_viewing.py new file mode 100644 index 0000000..1dbc310 --- /dev/null +++ b/examples/plot_07_geographic_viewing.py @@ -0,0 +1,30 @@ +""" +Viewing Geographic Projections +============================== + +Matplotview also works with matplotlib's built in geographic projections. +""" +import matplotlib.pyplot as plt +import numpy as np +from matplotview import view + +x = np.linspace(-2.5, 2.5, 20) +y = np.linspace(-1, 1, 20) +circ_gen = lambda: plt.Circle((1.5, 0.25), 0.7, ec="black", fc="blue") + +fig_test = plt.figure() + +# Plot in 2 seperate geographic projections... +ax_t1 = fig_test.add_subplot(1, 2, 1, projection="hammer") +ax_t2 = fig_test.add_subplot(1, 2, 2, projection="lambert") + +ax_t1.grid(True) +ax_t2.grid(True) + +ax_t1.plot(x, y) +ax_t1.add_patch(circ_gen()) + +view(ax_t2, ax_t1) + +fig_test.tight_layout() +fig_test.savefig("test7.png") diff --git a/examples/plot_08_viewing_2_axes.py b/examples/plot_08_viewing_2_axes.py new file mode 100644 index 0000000..16fdea6 --- /dev/null +++ b/examples/plot_08_viewing_2_axes.py @@ -0,0 +1,28 @@ +""" +Viewing Multiple Axes From A Single View +======================================== + +Views can view multiple axes at the same time, by simply calling :meth:`matplotview.view` multiple times. +""" +import matplotlib.pyplot as plt +from matplotview import view + +fig, (ax1, ax2, ax3) = plt.subplots(1, 3) + +# We'll plot 2 circles in axes 1 and 3. +ax1.add_patch(plt.Circle((1, 1), 1.5, ec="black", fc=(0, 0, 1, 0.5))) +ax3.add_patch(plt.Circle((3, 1), 1.5, ec="black", fc=(1, 0, 0, 0.5))) +for ax in (ax1, ax3): + ax.set_aspect(1) + ax.relim() + ax.autoscale_view() + +# Axes 2 is a view of 1 and 3 at the same time (view returns the axes it turns into a view...) +view(view(ax2, ax1), ax3) + +# Change data limits, so we can see the entire 'venn diagram' +ax2.set_aspect(1) +ax2.set_xlim(-0.5, 4.5) +ax2.set_ylim(-0.5, 2.5) + +fig.show() \ No newline at end of file diff --git a/examples/plot_09_artist_filtering.py b/examples/plot_09_artist_filtering.py new file mode 100644 index 0000000..e528fe4 --- /dev/null +++ b/examples/plot_09_artist_filtering.py @@ -0,0 +1,32 @@ +""" +Filtering Artists in a View +=========================== + +:meth:`matplotview.view` supports filtering out artist instances and types using the `filter_set` parameter, +which accepts an iterable of artists types and instances. +""" +import matplotlib.pyplot as plt +from matplotview import view + +fig, (ax1, ax2, ax3) = plt.subplots(3, 1) + +# Plot a line, circle patch, and some text in axes 1 +ax1.set_title("Original Plot") +ax1.plot([(i / 10) for i in range(10)], [(i / 10) for i in range(10)], "r") +ax1.add_patch(plt.Circle((0.5, 0.5), 0.25, ec="black", fc="blue")) +text = ax1.text(0.2, 0.2, "Hello World!", size=12) + +# Axes 2 is viewing axes 1, but filtering circles... +ax2.set_title("View Filtering Out Circles") +view(ax2, ax1, filter_set=[plt.Circle]) # We can pass artist types +ax2.set_xlim(ax1.get_xlim()) +ax2.set_ylim(ax1.get_ylim()) + +# Axes 3 is viewing axes 1, but filtering the text artist +ax3.set_title("View Filtering Out Just the Text Artist.") +view(ax3, ax1, filter_set=[text]) # We can also pass artist instances... +ax3.set_xlim(ax1.get_xlim()) +ax3.set_ylim(ax1.get_ylim()) + +fig.tight_layout() +fig.show() \ No newline at end of file diff --git a/examples/plot_10_line_scaling.py b/examples/plot_10_line_scaling.py new file mode 100644 index 0000000..9fe6179 --- /dev/null +++ b/examples/plot_10_line_scaling.py @@ -0,0 +1,29 @@ +""" +Disabling Line Scaling +====================== + +By default, matplotview scales the line thickness settings for lines and markers to match the zoom level. +This can be disabled via the `scale_lines` parameter of :meth:`matplotview.view`. +""" +import matplotlib.pyplot as plt +from matplotview import view + +fig, (ax1, ax2, ax3) = plt.subplots(3, 1) + +# Plot a line, and circle patch in axes 1 +ax1.set_title("Original Plot") +ax1.plot([(i / 10) for i in range(10)], [(i / 10) for i in range(10)], "r-") +ax1.add_patch(plt.Circle((0.5, 0.5), 0.1, ec="black", fc="blue")) + +ax2.set_title("Zoom View With Line Scaling") +view(ax2, ax1, scale_lines=True) # Default, line scaling is ON +ax2.set_xlim(0.33, 0.66) +ax2.set_ylim(0.33, 0.66) + +ax3.set_title("Zoom View Without Line Scaling") +view(ax3, ax1, scale_lines=False) # Line scaling is OFF +ax3.set_xlim(0.33, 0.66) +ax3.set_ylim(0.33, 0.66) + +fig.tight_layout() +fig.show() \ No newline at end of file diff --git a/examples/plot_11_image_interpolation.py b/examples/plot_11_image_interpolation.py new file mode 100644 index 0000000..06ecde5 --- /dev/null +++ b/examples/plot_11_image_interpolation.py @@ -0,0 +1,52 @@ +""" +Image Interpolation Methods +=========================== + +:meth:`matplotview.view` and :meth:`matplotview.inset_zoom_axes` support specifying an +image interpolation method via the `image_interpolation` parameter. This image interpolation +method is used to resize images when displaying them in the view. +""" +import matplotlib.pyplot as plt +from matplotview import view +import numpy as np + +fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2) + +fig.suptitle("Different interpolations when zoomed in on the bottom left corner.") + +ax1.set_title("Original") +ax1.imshow(np.random.rand(100, 100), cmap="Blues", origin="lower") +ax1.add_patch(plt.Rectangle((0, 0), 10, 10, ec="red", fc=(0, 0, 0, 0))) + +for ax, interpolation, title in zip([ax2, ax3, ax4], ["nearest", "bilinear", "bicubic"], ["Nearest (Default)", "Bilinear", "Cubic"]): + ax.set_title(title) + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + ax.set_aspect("equal") + view(ax, ax1, image_interpolation=interpolation) + +fig.tight_layout() +fig.show() + +#%% +# If you want to avoid interpolation artifacts, you can use `pcolormesh` instead of `imshow`. + +import matplotlib.pyplot as plt +from matplotview import view +import numpy as np + +fig, (ax1, ax2) = plt.subplots(1, 2) + +ax1.set_title("Original") +ax1.pcolormesh(np.random.rand(100, 100), cmap="Blues") +ax1.add_patch(plt.Rectangle((0, 0), 10, 10, ec="red", fc=(0, 0, 0, 0))) +ax1.set_aspect("equal") + +ax2.set_title("Zoomed in View") +ax2.set_xlim(0, 10) +ax2.set_ylim(0, 10) +ax2.set_aspect("equal") +view(ax2, ax1) + +fig.tight_layout() +fig.show() \ No newline at end of file diff --git a/examples/plot_12_editing_view_properties.py b/examples/plot_12_editing_view_properties.py new file mode 100644 index 0000000..b5cc3cb --- /dev/null +++ b/examples/plot_12_editing_view_properties.py @@ -0,0 +1,37 @@ +""" +Editing View Properties +======================= + +A view's properties can be edited by simply calling :meth:`matplotview.view` with the same axes arguments. +To stop a viewing, :meth:`matplotview.stop_viewing` can be used. +""" +import matplotlib.pyplot as plt +from matplotview import view, stop_viewing + +fig, (ax1, ax2, ax3) = plt.subplots(3, 1) + +# Plot a line, and circle patch in axes 1 +ax1.set_title("Original Plot") +ax1.plot([(i / 10) for i in range(10)], [(i / 10) for i in range(10)], "r-") +ax1.add_patch(plt.Circle((0.5, 0.5), 0.1, ec="black", fc="blue")) + +ax2.set_title("An Edited View") +# Ask ax2 to view ax1. +view(ax2, ax1, filter_set=[plt.Circle]) +ax2.set_xlim(0.33, 0.66) +ax2.set_ylim(0.33, 0.66) + +# Does not create a new view as ax2 is already viewing ax1. +# Edit ax2's viewing of ax1, remove filtering and disable line scaling. +view(ax2, ax1, filter_set=None, scale_lines=False) + +ax3.set_title("A Stopped View") +view(ax3, ax1) # Ask ax3 to view ax1. +ax3.set_xlim(0.33, 0.66) +ax3.set_ylim(0.33, 0.66) + +# This makes ax3 stop viewing ax1. +stop_viewing(ax3, ax1) + +fig.tight_layout() +fig.show() \ No newline at end of file diff --git a/matplotview/__init__.py b/matplotview/__init__.py index 4ac78cb..7f5bc07 100644 --- a/matplotview/__init__.py +++ b/matplotview/__init__.py @@ -10,7 +10,7 @@ from matplotview._docs import dynamic_doc_string, get_interpolation_list_str -__all__ = ["view", "inset_zoom_axes", "ViewSpecification"] +__all__ = ["view", "stop_viewing", "inset_zoom_axes"] @dynamic_doc_string( @@ -27,7 +27,9 @@ def view( ) -> Axes: """ Convert an axes into a view of another axes, displaying the contents of - the second axes. + the second axes. If this axes is already viewing the passed axes (This + function is called twice with the same axes arguments) this function + will update the settings of the viewing instead of creating a new view. Parameters ---------- @@ -63,6 +65,13 @@ def view( ------- axes The modified `~.axes.Axes` instance which is now a view. + The modification occurs in-place. + + See Also + -------- + matplotview.stop_viewing: Delete or stop an already constructed view. + matplotview.inset_zoom_axes: Convenience method for creating inset axes + that are views of the parent axes. """ view_obj = view_wrapper(type(axes)).from_axes(axes, render_depth) view_obj.view_specifications[axes_to_view] = ViewSpecification( @@ -73,6 +82,38 @@ def view( return view_obj +def stop_viewing(view: Axes, axes_of_viewing: Axes) -> Axes: + """ + Terminate the viewing of a specified axes. + + Parameters + ---------- + view: Axes + The axes the is currently viewing the `axes_of_viewing`... + + axes_of_viewing: Axes + The axes that the view should stop viewing. + + Returns + ------- + view + The view, which has now been modified in-place. + + Raises + ------ + AttributeError + If the provided `axes_of_viewing` is not actually being + viewed by the specified view. + + See Also + -------- + matplotview.view: To create views. + """ + view = view_wrapper(type(view)).from_axes(view) + del view.view_specifications[axes_of_viewing] + return view + + @dynamic_doc_string( render_depth=DEFAULT_RENDER_DEPTH, interp_list=get_interpolation_list_str() @@ -139,9 +180,9 @@ def inset_zoom_axes( ax The created `~.axes.Axes` instance. - Examples + See Also -------- - See `Axes.inset_axes` method for examples. + matplotview.view: For creating views in generalized cases. """ inset_ax = axes.inset_axes( bounds, transform=transform, zorder=zorder, **kwargs diff --git a/matplotview/_transform_renderer.py b/matplotview/_transform_renderer.py index ac1aab4..8c6e747 100644 --- a/matplotview/_transform_renderer.py +++ b/matplotview/_transform_renderer.py @@ -1,13 +1,23 @@ -from matplotlib.backend_bases import RendererBase +from typing import Tuple, Union +from matplotlib.axes import Axes +from matplotlib.backend_bases import RendererBase, GraphicsContextBase +from matplotlib.font_manager import FontProperties from matplotlib.patches import Rectangle +from matplotlib.texmanager import TexManager from matplotlib.transforms import Bbox, IdentityTransform, Affine2D, \ - TransformedPatchPath + TransformedPatchPath, Transform from matplotlib.path import Path import matplotlib._image as _image import numpy as np from matplotlib.image import _interpd_ from matplotview._docs import dynamic_doc_string, get_interpolation_list_str +ColorTup = Union[ + None, + Tuple[float, float, float, float], + Tuple[float, float, float] +] + class _TransformRenderer(RendererBase): """ @@ -19,12 +29,12 @@ class _TransformRenderer(RendererBase): @dynamic_doc_string(interp_list=get_interpolation_list_str()) def __init__( self, - base_renderer, - mock_transform, - transform, - bounding_axes, - image_interpolation="nearest", - scale_linewidths=True + base_renderer: RendererBase, + mock_transform: Transform, + transform: Transform, + bounding_axes: Axes, + image_interpolation: str = "nearest", + scale_linewidths: bool = True ): """ Constructs a new TransformRender. @@ -80,10 +90,10 @@ def __init__( ) @property - def bounding_axes(self): + def bounding_axes(self) -> Axes: return self.__bounding_axes - def _scale_gc(self, gc): + def _scale_gc(self, gc: GraphicsContextBase) -> GraphicsContextBase: with np.errstate(all='ignore'): transfer_transform = self._get_transfer_transform( IdentityTransform() @@ -103,14 +113,14 @@ def _scale_gc(self, gc): return new_gc - def _get_axes_display_box(self): + def _get_axes_display_box(self) -> Bbox: """ Private method, get the bounding box of the child axes in display coordinates. """ return self.__bounding_axes.get_window_extent() - def _get_transfer_transform(self, orig_transform): + def _get_transfer_transform(self, orig_transform: Transform) -> Transform: """ Private method, returns the transform which translates and scales coordinates as if they were originally plotted on the child axes @@ -141,43 +151,63 @@ def _get_transfer_transform(self, orig_transform): # We copy all of the properties of the renderer we are mocking, so that # artists plot themselves as if they were placed on the original renderer. @property - def height(self): + def height(self) -> int: return self.__renderer.get_canvas_width_height()[1] @property - def width(self): + def width(self) -> int: return self.__renderer.get_canvas_width_height()[0] - def get_text_width_height_descent(self, s, prop, ismath): + def get_text_width_height_descent( + self, + s: str, + prop: FontProperties, + ismath: bool + ) -> Tuple[float, float, float]: return self.__renderer.get_text_width_height_descent(s, prop, ismath) - def get_canvas_width_height(self): + def get_canvas_width_height(self) -> Tuple[float, float]: return self.__renderer.get_canvas_width_height() - def get_texmanager(self): + def get_texmanager(self) -> TexManager: return self.__renderer.get_texmanager() - def get_image_magnification(self): + def get_image_magnification(self) -> float: return self.__renderer.get_image_magnification() - def _get_text_path_transform(self, x, y, s, prop, angle, ismath): - return self.__renderer._get_text_path_transform(x, y, s, prop, angle, - ismath) + def _get_text_path_transform( + self, + x: float, + y: float, + s: str, + prop: FontProperties, + angle: float, + ismath: bool + ) -> Transform: + return self.__renderer._get_text_path_transform( + x, y, s, prop, angle, ismath + ) - def option_scale_image(self): + def option_scale_image(self) -> bool: return False - def points_to_pixels(self, points): + def points_to_pixels(self, points: float) -> float: return self.__renderer.points_to_pixels(points) - def flipy(self): + def flipy(self) -> bool: return self.__renderer.flipy() - def new_gc(self): + def new_gc(self) -> GraphicsContextBase: return self.__renderer.new_gc() # Actual drawing methods below: - def draw_path(self, gc, path, transform, rgbFace=None): + def draw_path( + self, + gc: GraphicsContextBase, + path: Path, + transform: Transform, + rgbFace: ColorTup = None + ): # Convert the path to display coordinates, but if it was originally # drawn on the child axes. path = path.deepcopy() @@ -203,7 +233,16 @@ def draw_path(self, gc, path, transform, rgbFace=None): self.__renderer.draw_path(gc, path, IdentityTransform(), rgbFace) - def _draw_text_as_path(self, gc, x, y, s, prop, angle, ismath): + def _draw_text_as_path( + self, + gc: GraphicsContextBase, + x: float, + y: float, + s: str, + prop: FontProperties, + angle: float, + ismath: bool + ): # If the text field is empty, don't even try rendering it... if((s is None) or (s.strip() == "")): return @@ -212,7 +251,13 @@ def _draw_text_as_path(self, gc, x, y, s, prop, angle, ismath): # checked above... (Above case causes error) super()._draw_text_as_path(gc, x, y, s, prop, angle, ismath) - def draw_gouraud_triangle(self, gc, points, colors, transform): + def draw_gouraud_triangle( + self, + gc: GraphicsContextBase, + points: np.ndarray, + colors: np.ndarray, + transform: Transform + ): # Pretty much identical to draw_path, transform the points and adjust # clip to the child axes bounding box. points = self._get_transfer_transform(transform).transform(points) @@ -233,7 +278,14 @@ def draw_gouraud_triangle(self, gc, points, colors, transform): IdentityTransform()) # Images prove to be especially messy to deal with... - def draw_image(self, gc, x, y, im, transform=None): + def draw_image( + self, + gc: GraphicsContextBase, + x: float, + y: float, + im: np.ndarray, + transform: Transform = None + ): mag = self.get_image_magnification() shift_data_transform = self._get_transfer_transform( IdentityTransform() diff --git a/matplotview/_view_axes.py b/matplotview/_view_axes.py index d505e1a..91c5e4b 100644 --- a/matplotview/_view_axes.py +++ b/matplotview/_view_axes.py @@ -107,8 +107,8 @@ class ViewSpecification: A view specification, or a mutable dataclass containing configuration options for a view's "viewing" of a different axes. - Parameters: - ----------- + Attributes + ---------- image_interpolation: string Supported options are {interp_list}. The default value is '{image_interpolation}'. This determines the interpolation diff --git a/requirements.txt b/requirements.txt index 564ed46..e685972 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ -matplotlib>=3.5.1 \ No newline at end of file +matplotlib>=3.5.1 +numpy \ No newline at end of file diff --git a/setup.py b/setup.py index 01127d8..b62ee79 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ import setuptools -VERSION = "0.2.0" +VERSION = "1.0.0" with open("README.md", "r", encoding="utf-8") as fh: long_description = fh.read() @@ -32,7 +32,8 @@ ], license="PSF", install_requires=[ - "matplotlib>=3.5.1" + "matplotlib>=3.5.1", + "numpy" ], packages=["matplotview"], python_requires=">=3.7",