pax_global_header00006660000000000000000000000064143146464470014527gustar00rootroot0000000000000052 comment=eea73780d65a42658297b0c9c7a3308706cdb35e mplcursors-0.5.2/000077500000000000000000000000001431464644700137445ustar00rootroot00000000000000mplcursors-0.5.2/.github/000077500000000000000000000000001431464644700153045ustar00rootroot00000000000000mplcursors-0.5.2/.github/workflows/000077500000000000000000000000001431464644700173415ustar00rootroot00000000000000mplcursors-0.5.2/.github/workflows/build.yml000066400000000000000000000041421431464644700211640ustar00rootroot00000000000000name: build on: [push, pull_request] jobs: build: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] flag: [""] include: - python-version: "3.6" flag: "oldest" - python-version: "3.10" flag: "pre" steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Install run: | case '${{ matrix.flag }}' in oldest) PYTEST_VERSION='==3.7.1' PYTEST_COV_VERSION='==2.9.0' NUMPY_VERSION='==1.11.*' MATPLOTLIB_VERSION='==3.1.0' ;; pre) PIP_INSTALL_PRE=true ;; esac && pip install --upgrade pip setuptools wheel pytest"$PYTEST_VERSION" pytest-cov"$PYTEST_COV_VERSION" && # Force install of numpy before matplotlib. pip install --upgrade --upgrade-strategy=only-if-needed --only-binary=:all: numpy"$NUMPY_VERSION" && pip install --upgrade --upgrade-strategy=only-if-needed matplotlib"$MATPLOTLIB_VERSION" && pip install . && pip list - name: Test run: | pyver="$(python -c 'import sys; print("{0.major}.{0.minor}".format(sys.version_info))')" && COVERAGE_FILE=".coverage.$pyver" python -mpytest --cov --cov-branch --cov-report=xml - name: Upload coverage uses: actions/upload-artifact@v2 with: name: coverage path: .coverage.* coverage: runs-on: ubuntu-latest needs: build steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 with: python-version: "3.10" - name: Download uses: actions/download-artifact@v2 with: name: coverage - name: Run run: | shopt -s globstar && python -mpip install --upgrade coverage && python -mcoverage combine .coverage.* && # Unifies paths across envs. python -mcoverage annotate && ( grep -HnTC2 '^!' **/*,cover || true ) && python -mcoverage report --show-missing mplcursors-0.5.2/.gitignore000066400000000000000000000002201431464644700157260ustar00rootroot00000000000000*.egg-info/ .cache/ .eggs/ .ipynb_checkpoints/ .pytest_cache/ build/ dist/ htmlcov/ oprofile_data/ .*.swp *.o *.pyc *.so .coverage .gdb_history mplcursors-0.5.2/.readthedocs.yaml000066400000000000000000000001261431464644700171720ustar00rootroot00000000000000version: 2 python: install: - path: . extra_requirements: - docs mplcursors-0.5.2/CHANGELOG.rst000066400000000000000000000074771431464644700160040ustar00rootroot00000000000000Changelog ========= 0.5.2 ----- - Fix compatibility with Matplotlib 3.6 and with PEP517 builds. - Non-multiple cursors can now be dragged. 0.5.1 ----- No new features; minor changes to docs. 0.5 --- - **Breaking change**: ``index`` is now a direct attribute of the `Selection`, rather than a sub-attribute via ``target``. (``Selection.target.index`` has been deprecated and will be removed in the future.) - Additional annotations are no longer created when dragging a ``multiple`` cursor. - Clicking on an annotation also updates the "current" selection for keyboard motion purposes. - Disabling a cursor also makes it unresponsive to motion keys. - Hovering is still active when the pan or zoom buttons are pressed (but not if there's a pan or zoom currently being selected). - Annotations are now :class:`~matplotlib.figure.Figure`-level artists, rather than Axes-level ones (so as to be drawn on top of twinned axes, if present). 0.4 --- - Invisible artists are now unpickable (patch suggested by @eBardieCT). - The ``bindings`` kwarg can require modifier keys for mouse button events. - Transient hovering (suggested by @LaurenceMolloy). - Switch to supporting only "new-style" (:class:`~matplotlib.collections.LineCollection`) :meth:`~matplotlib.axes.Axes.stem` plots. - Cursors are drawn with ``zorder=np.inf``. 0.3 --- - Updated dependency to Matplotlib 3.1 (``Annotation.{get,set}_anncoords``), and thus Python 3.6, numpy 1.11. - Display value in annotation for colormapped scatter plots. - Improve formatting of image values. - The add/remove callbacks no longer rely on Matplotlib's :class:`~matplotlib.cbook.CallbackRegistry`. `Cursor.connect` now returns the callback itself (simplifying its use as a decorator). `Cursor.disconnect` now takes two arguments: the event name and the callback function. Strong references are kept for the callbacks. - Overlapping annotations are now removed one at a time. - Re-clicking on an already selected point does not create a new annotation (patch suggested by @schneeammer). - :class:`~matplotlib.collections.PatchCollection`\s are now pickable (on their borders) (patch modified from a PR by @secretyv). - Support :class:`~matplotlib.collections.Collection`\s where :meth:`~matplotlib.collections.Collection.get_offset_transform()` is not ``transData`` (patch suggested by @yt87). - Support setting both ``hover`` and ``multiple``. - The ``artist`` attribute of Selections is correctly set to the :class:`~matplotlib.container.Container` when picking a :class:`~matplotlib.container.Container`, rather than to the internally used wrapper. 0.2.1 ----- No new features; test suite updated for compatibility with Matplotlib 3.0. Miscellaneous bugfixes. 0.2 --- - Updated dependency to Matplotlib 2.1 (2.0 gives more information about orientation of bar plots; 2.1 improves the handling of step plots). - Setting :envvar:`MPLCURSORS` hooks `Figure.draw ` (once per figure only) instead of `plt.show `, thus supporting figures created after the first call to `plt.show `. - Automatic positioning and alignment of annotation text. - Selections on images now have an index as well. - Selections created on :meth:`~matplotlib.axes.Axes.scatter` plots, :meth:`~matplotlib.axes.Axes.errorbar` plots, and :meth:`~matplotlib.axes.Axes.polar` plots can now be moved. - :class:`~matplotlib.collections.PathCollection`\s not created by :meth:`~matplotlib.axes.Axes.scatter` are now picked as paths, not as collections of points. - :class:`~matplotlib.patches.Patch`\es now pick on their borders, not their interior. - Improved picking of :class:`~matplotlib.container.Container`\s. - In hover mode, annotations can still be removed by right-clicking. Miscellaneous bugfixes. 0.1 --- - First public release. mplcursors-0.5.2/LICENSE.txt000066400000000000000000000020461431464644700155710ustar00rootroot00000000000000Copyright (c) 2016-present Antony Lee Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. mplcursors-0.5.2/README.rst000066400000000000000000000041521431464644700154350ustar00rootroot00000000000000Interactive data selection cursors for Matplotlib ================================================= | |GitHub| |PyPI| |conda-forge| |Debian| |Fedora| |openSUSE| | |Read the Docs| |Build| .. |GitHub| image:: https://img.shields.io/badge/github-anntzer%2Fmplcursors-brightgreen :target: https://github.com/anntzer/mplcursors .. |PyPI| image:: https://img.shields.io/pypi/v/mplcursors.svg?color=brightgreen :target: https://pypi.python.org/pypi/mplcursors .. |conda-forge| image:: https://img.shields.io/conda/v/conda-forge/mplcursors.svg?label=conda-forge&color=brightgreen :target: https://anaconda.org/conda-forge/mplcursors .. |Debian| image:: https://repology.org/badge/version-for-repo/debian_unstable/mplcursors.svg?header=Debian :target: https://packages.debian.org/sid/main/python3-mplcursors .. |Fedora| image:: https://repology.org/badge/version-for-repo/fedora_rawhide/python:mplcursors.svg?header=Fedora :target: https://src.fedoraproject.org/rpms/python-mplcursors .. |openSUSE| image:: https://repology.org/badge/version-for-repo/opensuse_tumbleweed/python:mplcursors.svg?header=openSUSE :target: https://software.opensuse.org/package/python3-mplcursors .. |Read the Docs| image:: https://img.shields.io/readthedocs/mplcursors :target: https://mplcursors.readthedocs.io/en/latest/?badge=latest .. |Build| image:: https://img.shields.io/github/workflow/status/anntzer/mplcursors/build :target: https://github.com/anntzer/mplcursors/actions mplcursors provides interactive data selection cursors for Matplotlib_. It is inspired from mpldatacursor_, with a much simplified API. mplcursors requires Matplotlib_\≥3.1. Read the documentation on `readthedocs.org`_. As usual, install using pip: .. code-block:: sh $ pip install mplcursors # from PyPI $ pip install git+https://github.com/anntzer/mplcursors # from Github or your favorite package manager. Run tests with pytest_\≥3.7.1. .. _Matplotlib: https://matplotlib.org .. _mpldatacursor: https://github.com/joferkington/mpldatacursor .. _pytest: https://pytest.org .. _readthedocs.org: https://mplcursors.readthedocs.org mplcursors-0.5.2/doc/000077500000000000000000000000001431464644700145115ustar00rootroot00000000000000mplcursors-0.5.2/doc/.gitignore000066400000000000000000000000301431464644700164720ustar00rootroot00000000000000build/ source/examples/ mplcursors-0.5.2/doc/Makefile000066400000000000000000000011141431464644700161460ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = python -msphinx SOURCEDIR = source 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) mplcursors-0.5.2/doc/make.bat000066400000000000000000000013561431464644700161230ustar00rootroot00000000000000@ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=python -msphinx ) set SOURCEDIR=source set BUILDDIR=build if "%1" == "" goto help %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The Sphinx module was not found. Make sure you have Sphinx installed, echo.then set the SPHINXBUILD environment variable to point to the full echo.path of the 'sphinx-build' executable. Alternatively you may add the echo.Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% :end popd mplcursors-0.5.2/doc/source/000077500000000000000000000000001431464644700160115ustar00rootroot00000000000000mplcursors-0.5.2/doc/source/_local_ext.py000066400000000000000000000003611431464644700204740ustar00rootroot00000000000000from sphinx_gallery.sorting import ExampleTitleSortKey class CustomSortKey(ExampleTitleSortKey): def __call__(self, filename): return ("" if filename == "basic.py" # goes first else super().__call__(filename)) mplcursors-0.5.2/doc/source/_static/000077500000000000000000000000001431464644700174375ustar00rootroot00000000000000mplcursors-0.5.2/doc/source/_static/hide_some_gallery_elements.css000066400000000000000000000001241431464644700255150ustar00rootroot00000000000000div.sphx-glr-download-link-note, div.sphx-glr-download-jupyter { display: none; } mplcursors-0.5.2/doc/source/changelog.rst000066400000000000000000000000411431464644700204650ustar00rootroot00000000000000.. include:: ../../CHANGELOG.rst mplcursors-0.5.2/doc/source/conf.py000066400000000000000000000051411431464644700173110ustar00rootroot00000000000000import os from pathlib import Path import re import sys import mplcursors # -- General configuration ------------------------------------------------ extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.autosummary', 'sphinx.ext.coverage', 'sphinx.ext.intersphinx', 'sphinx.ext.napoleon', 'sphinx.ext.viewcode', 'sphinx_gallery.gen_gallery', ] source_suffix = '.rst' exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] master_doc = 'index' project = 'mplcursors' copyright = '2016–present, Antony Lee' author = 'Antony Lee' # RTD modifies conf.py, making setuptools_scm mark the version as -dirty. version = release = re.sub(r'\.dirty$', '', mplcursors.__version__) language = 'en' default_role = 'any' pygments_style = 'sphinx' todo_include_todos = False python_use_unqualified_type_names = True # -- Options for HTML output ---------------------------------------------- html_theme = 'pydata_sphinx_theme' html_theme_options = { 'github_url': 'https://github.com/anntzer/mplcursors', } html_css_files = ['hide_some_gallery_elements.css'] html_static_path = ['_static'] htmlhelp_basename = 'mplcursors_doc' # -- Options for LaTeX output --------------------------------------------- latex_elements = {} latex_documents = [( master_doc, 'mplcursors.tex', 'mplcursors Documentation', 'Antony Lee', 'manual', )] # -- Options for manual page output --------------------------------------- man_pages = [( master_doc, 'mplcursors', 'mplcursors Documentation', [author], 1, )] # -- Options for Texinfo output ------------------------------------------- texinfo_documents = [( master_doc, 'mplcursors', 'mplcursors Documentation', author, 'mplcursors', 'Interactive data selection cursors for Matplotlib.', 'Miscellaneous', )] # -- Misc. configuration -------------------------------------------------- autodoc_member_order = 'bysource' intersphinx_mapping = { 'python': ('https://docs.python.org/3', None), 'matplotlib': ('https://matplotlib.org/stable', None), 'pandas': ('https://pandas.pydata.org/pandas-docs/stable', None), } # CustomSortKey cannot be defined *here* because it would be unpicklable as # this file is exec'd rather than imported. sys.path.append(".") from _local_ext import CustomSortKey os.environ.pop("DISPLAY", None) # Don't warn about non-GUI when running s-g. sphinx_gallery_conf = { 'backreferences_dir': None, 'examples_dirs': '../../examples', 'filename_pattern': r'.*\.py', 'gallery_dirs': 'examples', 'min_reported_time': 1, 'within_subsection_order': CustomSortKey, } mplcursors-0.5.2/doc/source/images/000077500000000000000000000000001431464644700172565ustar00rootroot00000000000000mplcursors-0.5.2/doc/source/images/basic.png000066400000000000000000001373141431464644700210560ustar00rootroot00000000000000PNG  IHDRY$sBIT|d pHYsaa?i IDATxyXa;ED2A4wETOi24%3KM4\i;`qA7n^lٲƚdee1a*WVlٲ >t8FMll,hZ6l`&}TV˛q$~ctyRfn#MOO'>>ޤL2&sEz'666z*W ͛7 ^;KKKҥK|fy3!'cx4 ,ܸq֭[ɣ/o_ݺucȐ!."R k׎)SyframmÇ駟F^ptt]vEڮrʌ9#Gr jԨ̙3?ݱcǨ]q.^h;ƍԫW5bTPիc0 ٙ}_Rzߟdʔ)c||}}h4GwfE?9rK,gϞ{>ptGB|8ӦMٳDFFbooYj111ӧL?Oׯq;;;}UTbŊ >˗/ºu눋+Utt4=+ԨQ7o~֮]kƍʏ?h\xI(4mڔÇ3e;֬Y-L>~SٳѣG3o<%22mz=6liӦnݚ9|*TC|,X[[[VիiӦ1.66͛ƍ`СXNJ+o>ILLt҄MW\1Q 0SNFѰ|rf͚… ywtTXRZ"3uT3gcǎ???vJӦM ݶnݺO=h >0~ڲ~z ĉjmۖ?bǽpuue݌?kײpB\]]RJs^ !iTaB!SG !B0R !B0R !B0R !B0R !B0R !B0R Q[o׶*U2أ־}{Wn\qF_/Fɓ';b7h -!oR ZhcccCٲeyW>9r(\b?L;w$**xL4-[X: !J$O~@FF{e޽|6>>kk뇚ˑ#GSNxxxlO?DZtl2ٳgӹsgmvל'7o5kp O)[,Zӯ_?RSS +.. 9w\1#F/xlgϞ@)ԩS Dˈ#HKKl޼t:UT19wRJ1gtxxxзo_^jk.7oz *kdee1a*WVlٲ >曜̈#(WvvvTX &cܹsQzwwwعs')))h4RSS6mӧHLOTR%$$ /ͨQprr?gtڕ?tޝ /SF TBz*QQQ|TP|7tޝʕ+oơCظq#|܉ԫW˗/ӯ_?tW888msE6mFaHMMeܹlڴ ^Ce̙DDD0x`8@ll,G/BDD< 'O&))A˰aØ9s&/̚5={jx"Zߟ(9s ֭3K.lܸ}RzuN<ٳ9z(6m*0[nѼys9믿Nʕٿ?QQQ\xO>;x`fϞMyQJ믿c֭֭[iݺ5 0 !%x,\P/TgΜQZxrssS*!!$Y 2ĸ*@߿ؖ|||Och4j߾}ƶ˗/+;;;5bcۊ+̶-LZZrssS*%%d]nn###UHHq PӧO7 P?~Zdԉ'ٳgUŊUժUU\\]sMO<4֭I{LLԚ5k 744Ty{{7nvܩfl?Mf={FQ .TJ)W_)@?s͚5 PV2iϻ~[n5fZٳd?Pi4u)R{U۷Vj*xxO={RByzꅫ+6l"} 4F6GGGzo|ݺuUqˋ@N>}߹\z~]awj*5kFƍSNѸqc ۶mޓ?6mB)ŰaLڇ -7n,pۤ$~Wz~ԩS$vŊЧO222?ժU|Gy^}y{{nW֭++]6ժU3ٶ}(ؾ};pzDEEqVp#`!`TV$/^^$Ν;G۶m+Uo|\]]v]u5vvv)S8N1((}&55xuVN:a0غu]M޸@v|~Ο?Ru&8O8Avv6nnnUJZhAxx8C aܸq4k֌uf|{ĉ\|( ĉ$$$uSNLٲe KaYR kРŏݻS~}wѣG߂QJu3sLrHH(Uǎ/9r sqqn_7/߲ezʹiصkRre|*hnn.?<|AȄ"O [[[>˜7o *0\r8q¬AQУ} /SpỾ,R<==}s9?G͍wy''' )ʕ+cǨ]=%%/Loh4|ñcLINNL2E*BCC e,[=zzjz쉿?~-իWt[nrYr%.\(.<rd O͛SzuNJvvvq-[dƶ/^|vtt&oӤIyܘ2eT-Ex;8n}M6^{tһ_б;%G}DVVV8;;ӰaC/^Lrr}Ϟ=ݻ$6o{xSܸql?yiff&p tbbbbM[.]8}47[wM222xgfn.޽/@!2Ç_fٲe1tP,X@XXCɉ ŋLձ&**^zjiڴi_̞=^zZjѳgO믿o)򾣢ذa-Z_jժdzj*6lؐ]s璔D>}0 DDD͚56""ڵk?f̙Ɓ?>aaa 0qD5kFÆ yWIJJbTZ8c\Z>|8ӦMٳDFFbooYj111Ӈs_Ҿ}{*VHJJ }Je˖tԉI&qA€BX+WܱӠAXz5:[naÆdddpaVXÇ)_Yd!ʛ~0[w-㣞}YC)/մiS锗zNi& ə3gعs'.]Hߜ}bggg4D1%==i!B<$K.TRiӆN:[^pFç~JTThZիMb_}Uʖ-ˁhҤ xxx;kɨQ(_NGxzz`ذaE9 3x`<==q̙ҥKo;C !##,oN:k IDATzJ*EΝ9{lo>4h^B ̜93߸K@@Z 6 6lwwwt:իWw[)|/~ԩIMͱۛѣG|r4 6 /#VZq!o޼t:UTaٲeE:GB OgyFW)k?(@۫~I㏪ZjOݺu۷o_e0T ܹs޽{Մ FQs5} PzRgV:uR1luVT.]֭[֭[ѣGRJXB1cƨ>L=Z*ooouuRjܹ P&M2qe7|Wkkk6l;cդI`_|Q 5zhh"5uT/_^9~ruuUjȑO?U 0(Ԏ;W_U;vPׯW~o *((HuI͝;W?Q޽I3ڶmfϞ}|||Tbbb9q2 OMRӧOWW9?~TDD;w\h.yK/d>l0])T\\j74;{Wƍ+t?={T:N;wvj- IkLԮ][թSv UddIlllL w}WjƶdcRfdd(OOOa͛7ݻ-44Ty{{7nvܩ)?X`ҥxzzҤI4 ]vef;:vF1.=:=}I ۷7i^Iƍ:tI܈#LJ)233 tQk^[燫X5 m۶qʕ"瑒O=0Yw5sйsg傮aAl[oi&֯_Off&###I:uضm[oڴgl+_<Ƈb~5uYYYjՊ??Mׯ66hР5j09::ҫW/۷ovvvvhxIIIgggקN:!J)(f999,_&MpN<ɓ'[.3ۦL2&k׮{xx5 &qΝTRxxx`ccSb3J*ammNCsYn޼ysy~ر2eРA&Lp;w999w砨װ .F}}}8qzm۶P`)))\vlnoƍiԨhZz=111dee>*U2ooosΙmm ?=%| XbO?qe/_/]-ZyN__j,( 2 0rH6l3VVVKf/<,E9'ziӦ^-[0i$&Mĺu {9(5_yMŲ}:ZW^$$$0gʗ/oRD0rHM6_,_2eʘ9:t( , ,,CĂ puuŋX^g}FΝ O>)Sgwlrĉ4k֌ ꫯөZ*qqq&ԵkW/PYd!>ڵS:.)DGڪD403L۷17nwUVUXQĨl[5nXzҸvӧrwwWYfСC*$$d Znzg… RXԼ{gvʕ3昖z- rttT5j\رcsUt:Fa\_s7 Lޔ?yΜ9cr> 7 ̹sTddrttTjРA*--,oU?rttTrʪwnF)vޭիZ*W>}&RSJժʕ+9sӧ&N|||NS 6T*_ٳIjڴtK{j P'O4_UxxrqqQ:NUXQjΝ&q6lPNKMzxh*B!D!233qttdĈƎ;'l2%x@!.qs%;;^xT.\H:u!1B!ʕ+?>k.,X@hhڡ4nܘ`?>.]b޼y^B!ݳ>5&Mƍxzz2`&Nh6g_XX+WdܹaZjeԅ(d B!D D<< 4nݺ2ۻB!JˁpwwP)ŋWwPB!vY>/D<NHHÃ|O)B4KJJٙ7oT!BF @!BF @!BF @!BF @!BF @!BF @!BF @!BF @!BF @!BF @!BF @!BF @!BlB!! .6$%Y:{"B!=PJqs:NƊd'$mӺ'6N@!Iy qǓ{7v¾V- gvoB!L~WCeejq ӻgR !B"u.⢢u5f vI(BD'O!o)]FahFc2o}hL~^xݭB!Crs7 osh(գ7mĩU'b6ll2:vݻ]6ڵcmtB!.1E~g+:}p3+^\dq|ܹXRzݕB!CFsm=B^Bc+#o!99Pcu0 ѤI&Mwdffi\N~gB!ē!M!e -Z(l==-S,Y&999 V\I͚5h֬TPSN1n87oj] !B+e'M"-Sϱc0w4J)dddpIn޼ɪUXh[n%$$,vZ"##w'##Q!(Tv6׾Y66o`eoo򕔔37o+;:ۃ#6lAll,_|YlŊqwwɓjM ;A!B~Ey(5BX™=ZeTcFFFE۩SHLL,t B!DqIN&a (3Çҩhڴ)$''W_{nFEZZ_ˋӧO_B!R͛{}rpqspv5..\`0̦Mhݺ5n"))7xWEf͈8B!׭C/`W<^QpW™Y^%$$!/!ԭ[\s?Dcgp{5,}y,_B!xK\xn:}zx]M1#B!xׯseʇ\k77<S۶Oŷ{B!xb)z5W|H͛tǰX;;[8ǗB!x"e<(mxEGa_3{I(B'JnFCV҃e4N B!);v7>IF3{H(B^֕+Ŀ>ɛah\^R !ⱥrr|9 gVV2o|T~I(BR_MƟ ;: ݳZ8'B!x䤤,-rsrt{*H(BǂR~ ~$p o;`a.R !.^$.f)۶`ر86 lbO))Ba1*+k0cTz:JtzO-)Bai .*U qh+UpfO?)BHܸ]\q~H(BGB)Eҷdr]lJpv%B!x2Ϝ!.zիvu8kFpvaP!(R\+Ss.]1|N|}}trݭB!SSG" [ R\z ͛ٸq#=;vd߾}|DEE1aC2e B!.7#+3fp}~ NLjTX jX:=iR;-UQQQ 2ڵknj3HOOà &0dȐ"h-$!➥_xѣ+cDQ$%%͛7qrrzu  /^Lrr2\rӧOӼysc^aÆܹ8w-B|d]al<=5̑+@K05s(l-()B!x'N"ylx5z M,(iB!2KI1ܴ4ƵwoJ%B!Caƍ#!!xFhDI&B$'0c&ח-rrc0\tFceeD 'BQR$='Svx|ww g'mR !?Cؕ+׸84h`̄0%Bԭ[\]9sPhlmq~aZ:=!H(B<'.*'WcV`̄(B!}Ⱦ~+Ӧqs*]]|g$Nڡh,אB{ƚoc,\:wF#"+and`N@!2O&n\i +: 瞳pfQrGjҳө\2ϸ>c鴊L @!.r32HS_YYht:Jkhlm-x_? |f39**UΰpfF @!)Jd?9f ve},xTR쿲.`uy%{K(B#;!? i&l<=|o'?*mlßs0 4 㕠W^?)B;\n|5Wb VVك҃``#>8 IDATXB<"SY8'B2rT +: }'N(ԬTV_#v.]lOOϧBQ妦l-Y99X?<.rkMADQ1MRKs]\+;-4m9s*;A2Jj[Z%(( ;3@La<{,>?뤉4KYNY_O;Ou B!xs!8= sbedvK/LgY2'T @[Xk-P!DTu<o.jدX1 Kb;Ї7!BfE"o ? ZzF 0x`;ȑ#?~s^CE!Wr QQT2oз/7^ǺMߺK\053}' ݽ3Lku_Z{3nDO7/5nTӳ؆ prrݝh"|||B43Pp4ClxTrj ќ? VeDftA@ 'l|T(uu2EQ7ngϞjVX V\<5$@NN_9B!'Ȉal:v'2]-LԕҪR%cyrΗ^kfjtNX qqq: @EQ={6qqqٳ__߫>.55@֯_ѣH8.BSY9K Qgp:V[4/2q%_&~Ŋٺ1T&tֿ._(̚5m۶k׮kxxx|̝;^x^BѼMT;AJ5 ϳr֞\KYu[3#dGap¦ @̙3ٽ{7v͟JII!''smll.B!L2E֭h[{5p2QN :>oO}Q1ѭ#.z AX8asiشi瓟@Vppp`֬Y7NGjj*QQQ]B_H_b*)|)'n( x{9~ => jZF}z)&LCE1h ,X@V7_vv6^^^2P!dDFR]׮" p2q+L;1=w*5{g 'l뎯_V_B!D3b,.&`2vvh1aTj㉛TebS&9U`nmftфw Y.%AP[\lGzxX8Yŕ|}kV[AVYNVN<S:NN B<{)c fp2qrrJ\EQU^v^L4?l kIR !e,YR^ Q˜F)0e XJS%mۢ3<`8k ' B )2"#8i k"" p2q3o໴0)&}PdfC";FbQ.^@EEPg6Z - sδlR&:?ޣqsp5J~~EQ8px.9~_ЇC ^)֭[8p`MکIEPUe$вeNcoooxB\AQ 6l w1^3MN' wgphTcF ڻpBgFQ6C`[zV~ɤϺu߳|G JEj*Q:MPHwp2WWM7,KXFzQ:vZ;#S-[Z8QWԛ'NpFtGyKjvܘ>݆evwmd 3r>?BekO *,dU*bǒW S01x"N(*)EIH˫=t&ˁ=烌1FSoQ:sн:֭[[8Q%85'PZ] ;Ogl[ɝJ @Q/IJ:H>}Νػ7SNѮ];KPuv6oCh7\F"b* lJDɼWk3:ARN @Q/ȡm[}y{;`k[FFF^)&W&Ř@u<7418uC;k>D} Pԋr;;vST٩.}ͅ剉\ A]Hg 'FQңK9u@߁Ղ" jX40L!SI }L`4vpp< AmЪLUl9hN@22`$!X8M֛o/VntDEO~m*#o11q\!;z;y^/og˖d**{`0kW뉉߆]8{_!6v,S܁ɤ0q|$:yq FZɡCq7ŜR5Щ'11ct 55#Gg5;:>q׬I` rcڴ;vܹB"#w3rJ~[qΟ'c"wU+tocN&'<+YpugjL0gkYלH(tn^}>OS4ݎx^}ܳgKZxÇ/\\w{Ǝ@|m2ȲecXh/vM}=Wps[7U_JsqWW;oAxD+jcƬ?"Qj PJKA]Y,O\$İZʍNwgthl,PXt`͘1_tδi=m[ _\K {\q%K~=݄ˆ U35_A~83O>ٓu7lH-0EtMLa߾t,pq XwvOԻ#GEEb"v=zMPПDDg߾30L-\*ż|MEEs14?ko,~鱫755~#|Xe֭0w}W<7#.{qva֩]QG o<|(n#12f ^/2)&vى!9VάY=k>vr!$ 0 } QQW)/&7- [O@yKZ=cG6o>y\xxؓw؆ \=_:#:#wwxӶ?ٽ4_k!ͪLK##j>%`."{3E}4V)uќ.< ښ19_g_ A42uZy3:9z5^^{{L&*?akU+gKX8sח'r/{|RR.m\}a @JJ99W ?*/7ot#?r)Je%*kk<̙WYqlYeY8Y;1D&wǟA4wRe޼T њd l4''k_ŕ6>>NLff1| WUSXXSOm&7 ΑAY`_EYWt:zcɒ(,lj]RYUG;Ra羂sh,h@aY26$oT ?3:`D4 Y"_Y"n,F1(X?Rb#x=<:`.FBnَI1oճ+=ޏZ%+ Y"T<Ʌ(~`|"W`xG ^8Xs_~Ct.Wg-P! SY9K>!7:Q3MJ+,T ;F!?pڻpBєȿx!h!cΞq tUk#Gyu97,ag;34Z:H=) "s"n@p8DAEV/+7maӂ<-,P4eR !D>TR naax~ V['- vcrP4YV9|WC^fP\\I~˴ha{VTT3o^@vv)[;cy饾5,嗷uk ׆>F`y g }珍˔)wp\!o[^?܉h'#'AyB]'* ?yR/ͦMTM݃\Їv(Vji"*-2fL09v,_/{k_$ 0P;95ZLJ-B,H v9ӻ'=WV P4ïΞ}_Ҳ#>/oƌ;ϺcǦM'n3~|G7k΍K姟Ρɓy8p{gзѣimVJ <VV+FΞǸqqu 44}WƛoᬢyP\,ϻxZOO kEaﹽ,=YPb@!z Es'h0&L̳noO2ry˴ غ5{gm[ _\K {֜}vqRR tctW̷+*̷v[++ ?pnW311}Y`u_OO%jU=GƂ6a`oطFV矿sΝ+dXƍ3}?E>LFD'W{BM@5?|%+WRPQ;S;MeB 8[˶za?䫯"!!VK^XhݺzΦgϞ|t-U}wk:t 6w5))VlIfذ̚S_o;Х7Gd;;#=zȑYSUe?ʆ6$_~[g0p` G޶M4|Ƌz=.+ޯQ+>GLB NX! FŠ XƎ1,Xs1㏉O?[n,\!Ck]|9N _9v, m4*TTߵj I..27 =ѣl ש Ð!|H%L)B d.|Z<^si!͂SR^x[OoŨ }~Ѩ5N(č}ܭ[77n/f֬YL>X|9>쭾hMW^NRR.W?Ts投ד[Jzz!))II9n팇 Edfpѣ89/qV1h?O? VͰaXp/.tDGռaeυÇ/0oLЙ/v;N~#W?u*SOoe֌uF#m=SdDEQz&H{N(3K㗲ܾ}z3Lzzgѩ9xzzEjj*o߾߿`EEEy:h=:ݻO״Zk'7|tW Ѽ|r399FwY#j^{m'.ijktt{}9{g nahL~RU}tT>;diR@R@2N,PRV7IQƍٳg9x tڕCq]w<.,,4vzH8^^^nkΜ90ٳ}j]O~sOk}wH=kz>(tq#c|п_Ǻuk 'k*lJDt|4 O`aLoj IDAT1L4_g_Raa!...|ӝ (̞=#GgԷu~ܹ 5X1EWߟNLK^Ugg;n6_zy8 }@n1փb֜XÊc+.ډ&2-PS'(̚5m۶k.|}tZ=>++fjlll.q Ν:}Lyy5?Y$tիzo1"Pq2g񚼜bŲ:i5EU)G^^u `%Esdb̙޽]vѦM1///ؾ};#F}oKFDM}\F'PWUVV|cPHFD$em袢 ldM_ZaM7T*p `F J#DuiشiEAV^`Μ9tޝ]pB ppp)(jQw(梡2-_F#j<{ɓPi픐lOێry7nCZ%+EwU05>=O?4гgOm&=ggg<DOwL'DO3w\Ν{/%1JEN=8tF rh}qw MՅ d;j ؿ5]զjKh@Jxp\l$Ds {zӵkWdLQ`4s`Ĉ{-ER]MފX?Zg'QY:^T^]$p,vZ;'S>>N(ei%;;///9s؏ps;It聓|OoEU<1 A`""HL{wtض& * *+Lr.@6L8I&VS]PԻgϲ{dco_ W+JR0]Bٳ/={t0 z}.~  ^/qPBTqu%85'PV]@+Vu clXrU4nRJdq JVVV888(sEQ(#1fv=_BftMO|j3&чv(ZtMC DΎ]Z:FRRRBll,ӧOVml*Ș}EDpN:JUs.]CmWvNOH(Ds=Gll,֭CZ4J.%TV}?(jkkKk2Lgb7p80*T >DO.N(D! H@@;wdԨQݛ8MECVrQQTЧ77m[kBLU|{[I ڊQy:.N(D# =Á wެ_{. Mu^Yxx+8ۏuˏ-'$+&tԎSpB!/)hؿ?ƍcРA0qDKb2Qv-Y @Gzy4u01[@^y_+),΃2e;I!nB4Pnnnl۶G}I&ܹsU<Ʌ(~Į[7 'k%&!)7nj F@QW&&&@͛GJJ tfTVFΒOȍjTvvx> naPimV%%a7Vч7Zv  @v"$n0m-X5~%DR 3g_MA}(޳ :kNq@têeK 'kE᧌0w~_>-KKvd/q3䟪KY,_%Ĵieرۗ8ڴicXMVUfoEі-h}|н6A,q3LңKύ@R3Pf̠{G 'TBNHBinPhR o"D#Hhh(lڴI~cH+L%%6mF`xV)YӅ0we G%9pbK.m/m h 6Iv =~rJFmXMBYBǛLvHl;UUTYĚk=KvY6L ɸM8sї~S혋7}~AaK(D#OXXcǎe<2g& 0P;90F ܌lbDz:i5Ux{)`%WSLpK ; x&*Vbܹ<󤤤)BѶ\,zzZ8]VFt|4ߤ|C @@f ?&pD4bՕpzK.ԎЦJ_ar9B4bjwy~iN>ʕ+qt9)Ysd,O=X{ kdS|NLFD$'O`]" pƥTͶۈN&1/JKh@( r pB( dw.--]UBkڵ+dȑۗ5k0tPKDz8ŋd| ^ehY8U>y=1 1+>֎A fXm^ߢbU{wmh B41ZbϞ=L8ÇdqKDzEQ(2yc^.kgϭ*(`eJV&$utqerL fܗ/qO_Y~ ,)hY~==O<)))[mUz(J] >KQcה]jʱ;OgL1iϾ+6ټZuy혝ۥ!pXK{%MV?$009sp)bbbkڿM~RU}F8*kkKkR.`7Gb<=}Z~u,'.K?ak[p֮ݠǛ!_5!0J?O۶m2e  bÆ x6w%s!*38c+ی݈_~pjbF +%c2_j[y NM)%*`!C1rHCTgsr| 7m@Wq:T?aRL9C_~@A~Ї)+mRUo8vL5hjϥr6 XqSz>|8{fݺuұnb2qq/TX*SܳhuU;Gt|4))X8靧oᄢI*77cN;v̶vPfm۶۷3d SNtRD:®K5lU|}k[Nf#wxeeᄢɹIq/mMZ6en{hen}PfE|<L6T^FsTZJGF#j{{<{ɓQi-Z|xy0T&t&CQ hmїqqNۺsglȻ͐5vkg}u_%[d&;=^tbbX cm9#ȼjQU{IqEgjTj][; B4S*y@xx8iii] 2.xZp0ļD G lMۊI1Ѩ5N(bHn.NlcZ;X۔r9UI(D37i$Zn͘1cӧqqq7Ju5y+bCRjqǓOtGQe":>}Ӳ=tͭ~@e 8H *`/ hC&m`8qÇSPPƍ-߹Iy'Hl;hh2}ύ@R3Pf̠{G 'Z \8837ere60Bۢ}㏌=￟XƏ_9EEd>+EAbxT2Y2 6ldY2 0w47d?]jʼr/o|,M:)wŋbݺu3f<<<˞ӿvU//#رp~a}]̙S/ E0.̷ƘQxZw?GEENZMXr_+gkg&Odrd%2-6e.ɮS[A@ڦ>)L%%%уiӦ1iҤ>fȑ̟?cGi*Ddkk˗_~I@@/")))|hoc3gȈO>5mEFp=5lV_5Wz{)?̹AiyFfHJjl\͘3-GѰ;Q5j|ݺuBfjEOb !bȐ!۷Çswy[_Oֻ`:4x #Ks!߇!O?ߺ?=ݽ[0hUpڦ̅gkTjս6evmk²6mZu]Ջ@lѣx[oETTT}DB܀8ȑ#{Yz5Pv-YcA-yG"WvzxIIhUZBB NkMAEA형#Pw\N`X L@@$''_u|ܹ 5X_Wݻy=1 1+>֎A LPx)=`s4<¼r9Ed0%%ko !,k2g~iRRR?LeeSr BegٸMCeռ.T2q%_|\m\q2'b#WEPNm|k2)MuIXTTDJJ eee:u#GE-5kƍCӑJTT~~~W+h4 |#(1?4e٫P9!nqE,+y^/2U*6̲e`bT(;֥e R8y._Rd|\: 7!m%/S#s"33h5r͙sQQg$)ϟ,nqTYql毇#wxiiib+KE߷PtvL1_ ar9E#/<{7ߤx.|}ѽ ~J``  &;u#,/UVaԎSaN2WY*/ߙPQX;f`<bB?^RVVV1kj[ۚǩT*^~e #==unu+('hSƹ 8M,xuCS`s2_ n^ke{QOBÿAɓ.*3aZnѣݻ7qqqk׮"sִ.MqAE@߁hn(9|qv-|=RSЪ'\Y"dxsq4-ZKssRRR %// 6ЧO)¡C ?xߖ}чKwW(c5-OaPenߢCnuKV !n;EQ(̷ܸ˸qxw\㏌3#}eyc)R I(LũSdDͧnnnn|w̜9'r)^~yX7)CZa6ƶi mWm.7CN.m4}r9R !0UT~JU*<|w Tַ|~VXA`` s%%%%K`e0U*i_2[L 䎓q՚MZnJm3?̎jW۟ϯhWhXAɏ?Eeý{uuT*QQQ裏ƚ5kpqq+J=)*@#Scoeol62ڢ/;qEw4eMB4c99d.7@8=m=;}t|}}7n{/7oƯ?s4M7T]jaðR7+UW=M3jZh{0 ZZ.@V )&|M{a*, I|94N׼ㄆR^^ΦMѣmͣG1qfʥ|wz݉>DOP]GRvRSM̕ŵc֎ny'!`r9 XqKʓNAّ#tOT$vwQY:vȁ5jcʕ5_GQ߇!O?:MaAW7A>0U׎9.5eV.*I @! Si)L޲0Qp2roܹiӦ1f>p!v͋/xS6UVIO@ʌ3hڸSK27A{CPsNi,R ,I7:oވi罊NgdfY_~g}/^Lrr2/cƌ!(U:[ιsixCu C0>oq pfmSg0{.};\I(DVA…}mKtӀNv%Z?={6O`0'|ŋ/2i%+$"7[7&OfbD\l,Xԁbyx,f&{2 k .X"B8],jUZ{-A@ԍJDEQ[Y IDATT5! $dL2{x'H2YuyIny~wf+m kp5/sgJ ^BrDHq%j+^t:. @k&]wq]J ]~n`i /ઌׇf;5WC;jW!xkq~̙~V=M!o9c^"&M"y23( ?<#Fwc466vZ-Z+waF Z/Y{ C@(s(u/_Lq2 qdPL~ZPQQ,!kMvI&~z='NJZZEEEh4]y|t裎瞙|&99=>9bN |}P?}vgy2AIVEyF|wM QW!;'$c0(,,-[e<른WFѠaztrsNrℸڠ#(⿦ Q#Z2gD ^B P(D?欨m @hJ ɹK1}v+>NyW=R=P-!L0*zTK?6}ś7z(_(,59\љ !PNj>4Á&$7$ƫEzjՄb7v9/Tg+@EUgf΄Q>4xu 1H(D?c8 gI "y齄QZK{_ @bD"1{IP5y_zzgu8~>'DPH(D?ᮯ?H3g@zh/f8|QF`!T'C}ǥ\kXῦȳ}3!.-xu !:H(D( OӠ\w%E}R{k+W0!a99\4"tZ]+hM*wMCSqL ч9JJZ}vF&y2 OreFQy|v䳎ǧ ƢELM: V5*~!2/WZ.о8.`& } N yr 'whBB]Ix=l*D|v@q٨!3.3"(sqi|g oH(DӲWt$}7o_%b宕7 g7Ã\7ڛæL9!D?% }bbMo>)?EvhuXg mDFqc֍4&\ժݷ 7꿦 զo5$he !4BPP_𶴀VK-Hկљp{š=kXW/7٘qzBdXj p¢ḁjӗ~ zoP j۳#Ksi۩fgg,\)k,cU. f/+B]?3 jӒ5 RNVB d +u׀׋h$qo4ۚoەNlLNv ?F )E_OZv^O1dbP^( ͛6QAUUD^q9I$s;1I'c{t#.dQ"&'A[զh_ !+ vTBW47ocX,x ꪀYbO>$555L:'|'v /*+Z>I^z/ na'uFwST_^gV,f/$=&=Bm}>8Bj(s,})@Ɣ)S[O3<äIX|93f̠(A>Kq[= !!A~Vw+UWqv5cqlLrLa~s 1Q]˚ @HxBIEQz"##ZLOOgΜ9/z8~AQ ԦׁGq̆aSA+.HS?{- L>㱈Mƶm~P(Dilc4[.&~GOL-YgVw+LX0~We\E^VzV6@N50GC qLBC=V6'%%;CO;O|.{ +.f@c=iWsq9Y3PȤ^E!O~'衇XlY8ʨ>}@hz:KxA)WەLJ>x3g~fU·d V\1M_%f ^Bducxuuuwy,^߭V+rPM^瞧gP\.4aa$g vyU|X!y4h2\V[+|* eey2 !]7f46mٳȧ~}jѫڳ )3o<"\v-$*5^ӷ XO+:%KmFSSwuVS{IwW_cxA!Hvtp,X[eQꡪĈDnw ׎S+6 Pؿ  c/yD@!Dptk`O@QKjZر YKiU4UrJ,~ Q,^ل~WX>͔͚^ !N\Xzڕ/ۍ` ׿"n<4GhOvLHE\4" >%ջ}8 72NPf!D7z G4oB:>1"dȐ Wv4EQ ^iæ({S} N50L/B `iv` C?c\UUT/@?twCy6o"oW{jled 1 7MwMi ߘ:=`ҚJjZ(QRBiM/ SK(D'MK/Q_w$6C 8X_wHy g:V~C)b5N!DSZK V%+ڨԄV ɑKq @ĉ$߷̾lb]:YCmo\X47fȍY7 !N:}͚ #|sywVJkl4|^d4D#_Gzwp;< _~mTŋZ4}h~5{ϢbsH6&s[`[+} M(I5[m$Y~ ][bQjm֎<F`R4Á&$7>2( [++/x({̓X]?P2_]˚%[Ul8jZV_Sä9" Y\ᬳH^R\.6m$w>ꙕ: I\.) T}Q3yoLr2 nʬkjz5pV]32,&m? ˟L#wuA_QvM 6 \3nw gy\pS_64Vii,ݸ) ( UMmjHƶ.2*qVݛE˞" E7<bbbZ[}[=ke48y\y=a(PF5}˗5 \ƾsGX|۶ƆyCӶc#dI($GI UKso@$/0yrP:rU{Viu04j}}Nsʼ>O}xϗv!J =EQjZXn*ZQHi5:mh$ {pI@1xڰԾ.pyqފ&$xߕ;eJ7LG?Z6| ԬbSvG ځ Dor޼.'lUD# e5/:bhSW:t$sÃ( _VIޮC2^6wc$fI(ݠ8xyYE}+.[KP#UW⍡P{.ˊ4$x3fWb y[4h2'W+~6}EV㿦 Ԧ/s&D ZBg.:{J^iU{La<)pܙ4R<_yZ-n&׿Ag2z=NwSX@6g02jdԧfo_|.ZX4Tm2CxTi(W^gbs0,&#F%|[sw&& \ھUW³I^K_]kqjѫ޳KȐHϺLB >Z\@5?z-e2 qBoYy5-X[wX|#5HD f~kQnjz$x]~3ZyiK]$F$r˸[v̵BMZO(pd7d;ZFXlp' 99*ܟi^rT$cP͛6QrUUD^~9IwIHW(o*g/^ӫ{T(f/dvlBul%턃C*4:H9?#vTz4uܪRPVmh$ cD窬j|!Ç|=?W]yl*߄w+ᢑ &(~_mN!ȸ2}̆)D:= ^-`^FSDCc"j&>Kq[ zsrHFDN G>]/ő/:?wعd05i,tX]6} Ny]!"Em[Jlhy!: )Nmy2L12 DPI(Oc#a:Ptјs76w*+wp]8?揟0SpB{_'qԗ^#ӗ8&85 CE k~qg# Ge%3=ߐP д+=w.;~>6߿Ⱥuٻ:â)&n̺!hv(e[YԹISnt{)Gumqgz,[f##2L;s8@}aii$.x=նjVY?ݭ127c.C6/m5iRxzx#sz#,2xu q mΎ5-w9LGݶMO4`qgbP}YMh( ?q & gC^o1dpyh&!CGt e>t/$KE}y56l]$Cu/:a7"Y'  _PӦ|= oW[*t<6U8~ⶦ- c0bsBBzix5,~oc#h4xߎ.*Gul#_V,y^śԦol_ 5f1~pSZ?xYm83,)*oUoht;I(z\[QUhJ]} ;!K8GFE0ce޸y${}{U!uo_trєeN=BƒWEHcQJklily:- ΋ BtiEb})jWۍ` ׿"n<4_ֲvZ ą1o<˼n^(`;#zB:k7s (ɥ_7zeV$'B:< ΄TJL/!$dȐn-yqu<ˆ,+ӯ$\OWm: 9:"FDZtqBdܙ}4[(/D_kNGܭx/>O:?kYdp֐_CK FnwQj5 /o)'EQ9uVRBE.j`dD#qF H077e˖<_ZwjR bDmUl)Bޮ<~ VeF f/d|n{^a-U|t6|Y`Yx);jF"MVoۦUu!1O)Sw{hOz=B_EybbM7Bpzl(@|4 TUWq[O^8۠6}C&>8 e?XEq0k3lXLDj^H Hb0d21i Ew^ f&G-lբWYg5V !\u=7yv@jWZ״zu=U8Touo[{qg!:FoYZ#'`k ۉh4rYgb F}u88c~}C>lo $e$C.x9V+/}WBK#2s PSOiWØml_ TØfAt ^OjhKy2IDATns3RՎu@(% YqLEy뭷hhh 33*x<Ȟ={?g.WtREO=ֆ&$ohkRT+Y_W]5f"Tׇ 4T@݃2GQgfTW`*nlU^XZV,wג`U>651L 1555Mcc#Q0AW絛'55e˖]ZK$/*7b gIҥkM<6o«+%FNvFH( TU}x=qe͂!K( ew+Qfmj^bdXpFY8JƝ !zAPz;wn0^|EQh\ÏWc%bN~ߚ+u{it\z 2&vLm-PYm6B[>B˚q\:nՖvZճgY1Sf,^i-Ze]ȑ#9r=ۋ(-*wѣI^a~6w׳rJ BOF34[j՝B?OgC<\hI/X&nI5:YW5{PV@LX 7e Y7۝WSe>BcS;2 qt{)Ql9ms[΢/:捌3CI6նjVY?m`q(gn\ !A^9zr?v}8fCb2Su6Vm!:;.k8jYD;B 0kj^0MfG"/qR?JJߝφ }Q(cGe.#D6(liC |!>tKZ7*^wf W*)CdW1I8)^/ ay1͠;fktZaAޮ;/JklZm8]$'E9k_"΄I84oLq9@嗓tIJawy}ڳ#6u!Fs-#_y\ȵ}P4Vi0˼N}Z8q^‘6u}57򬪩Px##,Fu0UefB #{0]p~z[^@|x< sf(ޤB P020NMVRkQ#JklHN07/İXw&AHq[ n~3?5*[*Y{6b3"r /?&L 3|VOzkDuGlHB~K8EQ4;ԕ<ͷ6y ]>OՐoU۱`" yBL0;vp$w}2!K ==l,ۈwb\8rs>r: BV*^K22w6Fu@r!$`%ƈ8!2L!$iOSu(袣1;E=aEQ^]y|RIg9 9|fEfx=$N΂1r)BIΫyVuvGFxc̼Ӷ&dܙB;sT6+VZ*w}y/[ʷ+oj,VҔKYqzhW:g=n_ӅBڅjӗyD&L `Qg56[H ӓf/Oud0 !@! `?je͞5+\Gqf5CS'w-'OL! ;\jgi+i`/奛$GjBcpayׯ@@ҝ j.ȗ7rJUWRSY8~!fnn'ت6}@SFM_u:Xx y4w=,L%-`5l ?B!N_?ilߢ:v8.q!Z)Fs5A-Ĩ˚ g PfMy-Ym83vԨ#b"dܙBn# `ؿ#hK²,\EQg#_t<~ayl:WN _lH=BO}EQjjU[ک;xqg:- /&qgB!z4}SOSn7_~^7n"oW{蘙: ;P;u1CaNٶ°;wo jzvxlz HBD1-L}:t%ǻ:4m/^+9Ԣ~n>G_-naiQ(ܿW_F)M/Ei5ڶd]qgOۦ'1H@BI>Umi~]C|䒀kt4J+% ᦬1FbcN썝v5m(Z5]_e6O8}{jlZZh>θ舐SD2L!D?$ `)//kNG$6F"oW_Y@G^LNv%v7p;Ԉ5V2S*TַvlmzYDc!#'B4yUߏ9{t4\^*)n(@se,Ԯ_A c., MqDz@~bQVky̼% Y!8A06%֧BikOiyYg5V1ucc޸y ]m<-,US2dT@ @^ր(wתwڦ&1ɸ3!Od+.ű_]3qɹK}/vZć3o<˼xS;ı|x=1K˗9 k̭N/pڶBռSXGJj^F1d5O!qOCG៯;hd*Yޠͣ 9 2JtV8r69r83L5Ţ-|[ et#͜N5~mO(X[yyWۏ;ld켴DqFw&B7"A3P(.pn2h5Z.M ?jWoS;<̓ ev:N۶po5/lddP !4O>\yS| LUWq[d^Q>kQ =<2ۜ1*O" Y<^IƝ !4']SCi*(gb =稜FCdh$7#F_|2Z/ Q#ZqD <^*[ΫQg: 8x>,-Ȩx#!,B fa:,>EaBX}n+afN: w.aQjsLnluQ)/}5@ 0yIQ2L!& ԶoUKsiF+KZJxI cSˬ;Q?)r/y&:6 l̷i: 9˗/q裏rf 'kQ']"{(rS4LT t0Ǚq94 6J6|NՆ8Β|1*yC#dܙB!M5,Z?̘1g}YfsN222zҴy3EgC|! 6VV51@h׆sx3/8A |sk괤&m}mDK@B!zFQJ^\r edd0g>L[[aa=?۶{5/`3 Ineac#\Z>QN>N^#<w&BDtt4DEw^YT?~8ӧm۶(9p?[|Xn-lhN6]U;Op^!%켎CFb ,BWfv;IIIf|Á?5|]Lx apLs,ڞ]t|G\p?5xk:tirL>ny~1 7u=|5xk:ti&{B!iB!iB!]nnnnkt:^xlZ@k:t#_ӾK!B 2r X!bP!bP!bP!b|222 O>vI$ӦM#&&fΜɎ;]F+V@__.E n&0L2o&ep8_FIDDGG vY;)((`ѢE`{̚5`&Nš5k3g/2L>D73exb6o_V+kKvv6;6mvO?t+ɟ6@Q>sOٶm[pJĩ/~ٳ*7|?D&Os=)8sشi%%%l۶;w2s W&:hnNRRRf U%(׿fԩL6-Sfv`"cV\Icc#F1j1dee<>vX T8+Vp0av?, `"!FᬳbӦMoڴs9'HUSz?$%%%%St饗sNvԩS;w.;v """%0m4+,,?phjx U%EV}/^̕W^c=ƌ3xgn vi$r-lذ5kP__O}}=Æ TTTFbccz\K, .`\}|駬^g}6إ+X|9#F 33m۶=D'I^^˗/СC;{/8ex|8 ,:. iB!(B1H(B1H(B1H(B1H(B1H(B1H(B1H(B1H(B1H(B1H(B1H(B1H(B1H(B1H(B1H(B1? VP=IENDB`mplcursors-0.5.2/doc/source/index.rst000066400000000000000000000266751431464644700176720ustar00rootroot00000000000000mplcursors – Interactive data selection cursors for Matplotlib ============================================================== |GitHub| |PyPI| |conda-forge| |Debian| |Fedora| |openSUSE| .. |GitHub| image:: https://img.shields.io/badge/github-anntzer%2Fmplcursors-brightgreen :target: https://github.com/anntzer/mplcursors .. |PyPI| image:: https://img.shields.io/pypi/v/mplcursors.svg :target: https://pypi.python.org/pypi/mplcursors .. |conda-forge| image:: https://img.shields.io/conda/v/conda-forge/mplcursors.svg?label=conda-forge :target: https://anaconda.org/conda-forge/mplcursors .. |Debian| image:: https://repology.org/badge/version-for-repo/debian_unstable/mplcursors.svg?header=Debian :target: https://packages.debian.org/sid/main/python3-mplcursors .. |Fedora| image:: https://repology.org/badge/version-for-repo/fedora_rawhide/python:mplcursors.svg?header=Fedora :target: https://src.fedoraproject.org/rpms/python-mplcursors .. |openSUSE| image:: https://repology.org/badge/version-for-repo/opensuse_tumbleweed/python:mplcursors.svg?header=openSUSE :target: https://software.opensuse.org/package/python3-mplcursors :mod:`mplcursors` provides interactive data selection cursors for Matplotlib_. It is inspired from mpldatacursor_, with a much simplified API. .. _Matplotlib: https://matplotlib.org .. _mpldatacursor: https://github.com/joferkington/mpldatacursor :mod:`mplcursors` requires Python 3, and Matplotlib≥3.1. .. _installation: Installation ------------ Pick one among: .. code-block:: sh $ pip install mplcursors # from PyPI $ pip install git+https://github.com/anntzer/mplcursors # from Github .. _basic-example: Basic example ------------- Basic examples work similarly to mpldatacursor_:: import matplotlib.pyplot as plt import numpy as np import mplcursors data = np.outer(range(10), range(1, 5)) fig, ax = plt.subplots() lines = ax.plot(data) ax.set_title("Click somewhere on a line.\nRight-click to deselect.\n" "Annotations can be dragged.") mplcursors.cursor(lines) # or just mplcursors.cursor() plt.show() .. image:: /images/basic.png The `cursor` convenience function makes a collection of artists selectable. Specifically, its first argument can either be a list of artists or axes (in which case all artists in each of the axes become selectable); or one can just pass no argument, in which case all artists in all figures become selectable. Other arguments (which are all keyword-only) allow for basic customization of the `Cursor`’s behavior; please refer to that class' documentation. .. _activation-by-environment-variable: Activation by environment variable ---------------------------------- It is possible to use :mod:`mplcursors` without modifying *any* source code: setting the :envvar:`MPLCURSORS` environment variable to a JSON-encoded dict will patch `Figure.draw ` to automatically call `cursor` (with the passed keyword arguments, if any) after the figure is drawn for the first time (more precisely, after the first draw that includes a selectable artist). Typical settings include:: $ MPLCURSORS={} python foo.py and:: $ MPLCURSORS='{"hover": 1}' python foo.py Note that this will only work if :mod:`mplcursors` has been installed, not if it is simply added to the :envvar:`PYTHONPATH`. Note that this will not pick up artists added to the figure after the first draw, e.g. through interactive callbacks. .. _default-ui: Default UI ---------- - A left click on a line (a point, for plots where the data points are not connected) creates a draggable annotation there. Only one annotation is displayed (per `Cursor` instance), except if the ``multiple`` keyword argument was set. - A right click on an existing annotation will remove it. - Clicks do not trigger annotations if the zoom or pan tool are active. It is possible to bypass this by *double*-clicking instead. - For annotations pointing to lines or images, :kbd:`Shift-Left` and :kbd:`Shift-Right` move the cursor "left" or "right" by one data point. For annotations pointing to images, :kbd:`Shift-Up` and :kbd:`Shift-Down` are likewise available. - :kbd:`v` toggles the visibility of the existing annotation(s). - :kbd:`e` toggles whether the `Cursor` is active at all (if not, no event other than re-activation is propagated). These bindings are all customizable via `Cursor`’s ``bindings`` keyword argument. Note that the keyboard bindings are only active if the canvas has the keyboard input focus. .. _customization: Customization ------------- Instead of providing a host of keyword arguments in `Cursor`’s constructor, :mod:`mplcursors` represents selections as `Selection` objects and lets you hook into their addition and removal. Specifically, a `Selection` has the following fields: - :attr:`.artist`: the selected artist, - :attr:`.target`: the ``(x, y)`` coordinates of the point picked within the artist. - :attr:`.index`: an index of the selected point, within the artist data, as detailed below. - :attr:`.dist`: the distance from the point clicked to the :attr:`.target` (mostly used to decide which artist to select). - :attr:`.annotation`: a Matplotlib :class:`~matplotlib.text.Annotation` object. - :attr:`.extras`: an additional list of artists, that will be removed whenever the main :attr:`.annotation` is deselected. The exact meaning of :attr:`.index` depends on the selected artist: - For :class:`~matplotlib.lines.Line2D`\s, the integer part of :attr:`.index` is the index of segment where the selection is, and its fractional part indicates where the selection is within that segment. For step plots (i.e., created by `plt.step ` or `plt.plot `\ ``(..., drawstyle="steps-...")``, we return a special :class:`Index` object, with attributes :attr:`int` (the segment index), :attr:`x` (how far the point has advanced in the ``x`` direction) and :attr:`y` (how far the point has advanced in the ``y`` direction). See `/examples/step` for an example. On polar plots, lines can be either drawn with a "straight" connection between two points (in screen space), or "curved" (i.e., using linear interpolation in data space). In the first case, the fractional part of the index is defined as for cartesian plots. In the second case, the index in computed first on the interpolated path, then divided by the interpolation factor (i.e., pretending that each interpolated segment advances the same index by the same amount). - For :class:`~matplotlib.image.AxesImage`\s, :attr:`.index` are the ``(y, x)`` indices of the selected point, such that ``data[y, x]`` is the value at that point (note that the indices are thus in reverse order compared to the ``(x, y)`` target coordinates!). - For :class:`~matplotlib.container.Container`\s, :attr:`.index` is the index of the selected sub-artist. - For :class:`~matplotlib.collections.LineCollection`\s and :class:`~matplotlib.collections.PathCollection`\s, :attr:`.index` is a pair: the index of the selected line, and the index within the line, as defined above. (Note that although `Selection` is implemented as a namedtuple, only the field names should be considered stable API. The number and order of fields is subject to change with no notice.) Thus, in order to customize, e.g., the annotation text, one can call:: lines = ax.plot(range(3), range(3), "o") labels = ["a", "b", "c"] cursor = mplcursors.cursor(lines) cursor.connect( "add", lambda sel: sel.annotation.set_text(labels[sel.index])) Whenever a point is selected (resp. deselected), the ``"add"`` (resp. ``"remove"``) event is triggered and the registered callbacks are executed, with the `Selection` as only argument. Here, the only callback updates the text of the annotation to a per-point label. (``cursor.connect("add")`` can also be used as a decorator to register a callback, see below for an example.) For an example using pandas' `DataFrame `\s, see `/examples/dataframe`. For additional examples of customization of the position and appearance of the annotation, see `/examples/bar` and `/examples/change_popup_color`. .. note:: When the callback is fired, the position of the annotating text is temporarily set to ``(nan, nan)``. This allows us to track whether a callback explicitly sets this position, and, if none does, automatically compute a suitable position. Likewise, if the text alignment is not explicitly set but the position is, then a suitable alignment will be automatically computed. Callbacks can also be used to make additional changes to the figure when a selection occurs. For example, the following snippet (extracted from `/examples/paired_highlight`) ensures that whenever an artist is selected, another artist that has been "paired" with it (via the ``pairs`` map) also gets selected:: @cursor.connect("add") def on_add(sel): sel.extras.append(cursor.add_highlight(pairs[sel.artist])) Note that the paired artist will also get de-highlighted when the "first" artist is deselected. In order to set the status bar text from a callback, it may be helpful to clear it during "normal" mouse motion, e.g.:: fig.canvas.mpl_connect( "motion_notify_event", lambda event: fig.canvas.toolbar.set_message("")) cursor = mplcursors.cursor(hover=True) cursor.connect( "add", lambda sel: fig.canvas.toolbar.set_message( sel.annotation.get_text().replace("\n", "; "))) .. _complex-plots: Complex plots ------------- Some complex plots, such as contour plots, may be partially supported, or not at all. Typically, it is because they do not subclass :class:`~matplotlib.artist.Artist`, and thus appear to `cursor` as a collection of independent artists (each contour level, in the case of contour plots). It is usually possible, again, to hook the ``"add"`` signal to provide additional information in the annotation text. See `/examples/contour` for an example. Animations ---------- Matplotlib's :mod:`.animation` blitting mode assumes that the animation object is entirely in charge of deciding what artists to draw and when. In particular, this means that the ``animated`` property is set on certain artists. As a result, when :mod:`mplcursors` tries to blit an animation on top of the image, the animated artists will not be drawn, and disappear. More importantly, it also means that once an annotation is added, :mod:`mplcursors` cannot remove it (as it needs to know what artists to redraw to restore the original state). As a workaround, either switch off blitting, or unset the ``animated`` property on the relevant artists before using a cursor. (The only other fix I can envision is to walk the entire tree of artists, record their visibility status, and try to later restore them; but this would fail for :class:`~matplotlib.animation.ArtistAnimation`\s which themselves fiddle with artist visibility). Users ----- Some users of mplcursors (please let me know if you find this package useful!): - `reliability `_: A Python library for reliability engineering. - `RepoDash `_: Performance metrics for Github repositories. - `topplot `_: Munge top logs in to graphs. Indices and tables ================== * `genindex` * `modindex` * `search` .. toctree:: :hidden: Main page API Examples Changelog mplcursors-0.5.2/doc/source/modules.rst000066400000000000000000000001151431464644700202100ustar00rootroot00000000000000:orphan: mplcursors ========== .. toctree:: :maxdepth: 4 mplcursors mplcursors-0.5.2/doc/source/mplcursors.rst000066400000000000000000000013101431464644700207470ustar00rootroot00000000000000The mplcursors API ================== .. module:: mplcursors | .. autosummary:: mplcursors.Cursor mplcursors.cursor mplcursors.HoverMode mplcursors.Selection mplcursors.compute_pick mplcursors.get_ann_text mplcursors.move mplcursors.make_highlight | .. autoclass:: mplcursors.Cursor :members: :special-members: __init__ :undoc-members: .. autofunction:: mplcursors.cursor .. autoclass:: mplcursors.HoverMode :members: :undoc-members: .. autoclass:: mplcursors.Selection :members: :undoc-members: .. autofunction:: mplcursors.compute_pick .. autofunction:: mplcursors.get_ann_text .. autofunction:: mplcursors.move .. autofunction:: mplcursors.make_highlight mplcursors-0.5.2/examples/000077500000000000000000000000001431464644700155625ustar00rootroot00000000000000mplcursors-0.5.2/examples/README.txt000066400000000000000000000003171431464644700172610ustar00rootroot00000000000000.. _examples: :mod:`mplcursors` examples ========================== As `mplcursors` is fundamentally a library for interactivity, you should download the examples and try them yourself :-) .. vim: ft=rst mplcursors-0.5.2/examples/artist_labels.py000066400000000000000000000012101431464644700207560ustar00rootroot00000000000000""" Display an artist's label instead of x, y coordinates ===================================================== Use an event handler to change the annotation text. """ import numpy as np import matplotlib.pyplot as plt import mplcursors x = np.linspace(0, 10, 100) fig, ax = plt.subplots() ax.set_title("Click on a line to display its label") # Plot a series of lines with increasing slopes. for i in range(1, 20): ax.plot(x, i * x, label=f"$y = {i}x$") # Use a Cursor to interactively display the label for a selected line. mplcursors.cursor().connect( "add", lambda sel: sel.annotation.set_text(sel.artist.get_label())) plt.show() mplcursors-0.5.2/examples/bar.py000066400000000000000000000017701431464644700167050ustar00rootroot00000000000000""" Display a bar's height and name on top of it upon hovering ========================================================== Using an event handler to change the annotation text and position. """ import string import matplotlib.pyplot as plt import mplcursors fig, ax = plt.subplots() ax.bar(range(9), range(1, 10), align="center") labels = string.ascii_uppercase[:9] ax.set(xticks=range(9), xticklabels=labels, title="Hover over a bar") # With HoverMode.Transient, the annotation is removed as soon as the mouse # leaves the artist. Alternatively, one can use HoverMode.Persistent (or True) # which keeps the annotation until another artist gets selected. cursor = mplcursors.cursor(hover=mplcursors.HoverMode.Transient) @cursor.connect("add") def on_add(sel): x, y, width, height = sel.artist[sel.index].get_bbox().bounds sel.annotation.set(text=f"{x+width/2}: {height}", position=(0, 20), anncoords="offset points") sel.annotation.xy = (x + width / 2, y + height) plt.show() mplcursors-0.5.2/examples/basic.py000066400000000000000000000007051431464644700172170ustar00rootroot00000000000000""" mplcursors' core functionality ============================== ... is to add interactive data cursors to a figure. """ import matplotlib.pyplot as plt import numpy as np import mplcursors data = np.outer(range(10), range(1, 5)) fig, ax = plt.subplots() lines = ax.plot(data) ax.set_title("Click somewhere on a line.\nRight-click to deselect.\n" "Annotations can be dragged.") fig.tight_layout() mplcursors.cursor(lines) plt.show() mplcursors-0.5.2/examples/change_popup_color.py000066400000000000000000000017451431464644700220110ustar00rootroot00000000000000""" Changing properties of the popup ================================ Use an event handler to customize the popup. """ import matplotlib.pyplot as plt import numpy as np import mplcursors fig, axes = plt.subplots(ncols=2) left_artist = axes[0].plot(range(11)) axes[0].set(title="No box, different position", aspect=1) right_artist = axes[1].imshow(np.arange(100).reshape(10, 10)) axes[1].set(title="Fancy white background") # Make the text pop up "underneath" the line and remove the box... c1 = mplcursors.cursor(left_artist) @c1.connect("add") def _(sel): sel.annotation.set(position=(15, -15)) # Note: Needs to be set separately due to matplotlib/matplotlib#8956. sel.annotation.set_bbox(None) # Make the box have a white background with a fancier connecting arrow c2 = mplcursors.cursor(right_artist) @c2.connect("add") def _(sel): sel.annotation.get_bbox_patch().set(fc="white") sel.annotation.arrow_patch.set(arrowstyle="simple", fc="white", alpha=.5) plt.show() mplcursors-0.5.2/examples/contour.py000066400000000000000000000017741431464644700176360ustar00rootroot00000000000000r""" Contour plots ============= Contour plot support is limited to picking the individual :class:`~matplotlib.collections.LineCollection`\s, which are directly registered with the axes and thus picked up by `mplcursors.cursor` (:class:`~matplotlib.contour.QuadContourSet`\s are not even artists, which make them hard to handle without additional special-casing). It remains possible to retrieve the ``z`` value and add it manually to the annotation, though. """ import numpy as np import matplotlib.pyplot as plt import mplcursors np.random.seed(42) fig, ax = plt.subplots() cf = ax.contour(np.random.random((10, 10))) cursor = mplcursors.cursor() @cursor.connect("add") def on_add(sel): ann = sel.annotation # `cf.collections.index(sel.artist)` is the index of the selected line # among all those that form the contour plot. # `cf.cvalues[...]` is the corresponding value. ann.set_text("{}\nz={:.3g}".format( ann.get_text(), cf.cvalues[cf.collections.index(sel.artist)])) plt.show() mplcursors-0.5.2/examples/dataframe.py000066400000000000000000000032551431464644700200650ustar00rootroot00000000000000""" Extracting data and labels from a :class:`~pandas.DataFrame` ============================================================ :class:`~pandas.DataFrame`\\s can be used similarly to any other kind of input. Here, we generate a scatter plot using two columns and label the points using all columns. This example also applies a shadow effect to the hover panel. """ from matplotlib import pyplot as plt from matplotlib.patheffects import withSimplePatchShadow import mplcursors from pandas import DataFrame df = DataFrame( dict( Suburb=["Ames", "Somerset", "Sawyer"], Area=[1023, 2093, 723], SalePrice=[507500, 647000, 546999], ) ) df.plot.scatter(x="Area", y="SalePrice", s=100) def show_hover_panel(get_text_func=None): cursor = mplcursors.cursor( hover=2, # Transient annotation_kwargs=dict( bbox=dict( boxstyle="square,pad=0.5", facecolor="white", edgecolor="#ddd", linewidth=0.5, path_effects=[withSimplePatchShadow(offset=(1.5, -1.5))], ), linespacing=1.5, arrowprops=None, ), highlight=True, highlight_kwargs=dict(linewidth=2), ) if get_text_func: cursor.connect( event="add", func=lambda sel: sel.annotation.set_text(get_text_func(sel.index)), ) return cursor def on_add(index): item = df.iloc[index] parts = [ f"Suburb: {item.Suburb}", f"Area: {item.Area:,.0f}m²", f"Sale price: ${item.SalePrice:,.0f}", ] return "\n".join(parts) show_hover_panel(on_add) plt.show() # test: skip mplcursors-0.5.2/examples/date.py000066400000000000000000000007741431464644700170610ustar00rootroot00000000000000""" Datetime data ============= mplcursors correctly formats datetime data. """ import datetime as dt import numpy as np import matplotlib.pyplot as plt import matplotlib.dates as mdates import mplcursors t = mdates.drange(dt.datetime(2014, 1, 15), dt.datetime(2014, 2, 27), dt.timedelta(hours=2)) y = np.sin(t) fig, ax = plt.subplots() ax.plot_date(t, y, "-") fig.autofmt_xdate() # Note that mplcursors will automatically display the x-values as dates. mplcursors.cursor() plt.show() mplcursors-0.5.2/examples/highlight.py000066400000000000000000000007221431464644700201040ustar00rootroot00000000000000""" Highlighting the artist upon selection ====================================== Just pass ``highlight=True`` to `cursor`. """ import numpy as np import matplotlib.pyplot as plt import mplcursors x = np.linspace(0, 10, 100) fig, ax = plt.subplots() # Plot a series of lines with increasing slopes. lines = [] for i in range(1, 20): line, = ax.plot(x, i * x, label=f"$y = {i}x$") lines.append(line) mplcursors.cursor(lines, highlight=True) plt.show() mplcursors-0.5.2/examples/hover.py000066400000000000000000000006261431464644700172630ustar00rootroot00000000000000""" Annotate on hover ================= When *hover* is set to ``True``, annotations are displayed when the mouse hovers over the artist, without the need for clicking. """ import matplotlib.pyplot as plt import numpy as np import mplcursors np.random.seed(42) fig, ax = plt.subplots() ax.scatter(*np.random.random((2, 26))) ax.set_title("Mouse over a point") mplcursors.cursor(hover=True) plt.show() mplcursors-0.5.2/examples/image.py000066400000000000000000000007411431464644700172200ustar00rootroot00000000000000""" Cursors on images ================= ... display the underlying data value. """ import matplotlib.pyplot as plt import numpy as np import mplcursors data = np.arange(100).reshape((10, 10)) fig, axes = plt.subplots(ncols=2) axes[0].imshow(data, interpolation="nearest", origin="lower") axes[1].imshow(data, interpolation="nearest", origin="upper", extent=[200, 300, 400, 500]) mplcursors.cursor() fig.suptitle("Click anywhere on the image") plt.show() mplcursors-0.5.2/examples/keyboard_shortcuts.py000066400000000000000000000007501431464644700220540ustar00rootroot00000000000000""" Keyboard shortcuts ================== By default, mplcursors uses "t" to toggle interactivity and "d" to hide/show annotation boxes. These shortcuts can be customized. """ import matplotlib.pyplot as plt import mplcursors fig, ax = plt.subplots() ax.plot(range(10), "o-") ax.set_title('Press "e" to enable/disable the datacursor\n' 'Press "h" to hide/show any annotation boxes') mplcursors.cursor(bindings={"toggle_visible": "h", "toggle_enabled": "e"}) plt.show() mplcursors-0.5.2/examples/labeled_points.py000066400000000000000000000010731431464644700211210ustar00rootroot00000000000000""" Displaying a custom label for each individual point =================================================== mpldatacursor's *point_labels* functionality can be emulated with an event handler that sets the annotation text with a label selected from the target index. """ import matplotlib.pyplot as plt import mplcursors import numpy as np labels = ["a", "b", "c", "d", "e"] x = np.array([0, 1, 2, 3, 4]) fig, ax = plt.subplots() line, = ax.plot(x, x, "ro") mplcursors.cursor(ax).connect( "add", lambda sel: sel.annotation.set_text(labels[sel.index])) plt.show() mplcursors-0.5.2/examples/nondraggable.py000066400000000000000000000012071431464644700205570ustar00rootroot00000000000000""" Using multiple annotations and disabling draggability via signals ================================================================= By default, each `Cursor` will ever display one annotation at a time. Pass ``multiple=True`` to display multiple annotations. Annotations can be made non-draggable by hooking their creation. """ import matplotlib.pyplot as plt import numpy as np import mplcursors data = np.outer(range(10), range(1, 5)) fig, ax = plt.subplots() ax.set_title("Multiple non-draggable annotations") ax.plot(data) mplcursors.cursor(multiple=True).connect( "add", lambda sel: sel.annotation.draggable(False)) plt.show() mplcursors-0.5.2/examples/paired_highlight.py000066400000000000000000000015561431464644700214360ustar00rootroot00000000000000""" Linked artists ============== An example of connecting to cursor events: when an artist is selected, also highlight its "partner". """ import numpy as np import matplotlib.pyplot as plt import mplcursors def main(): fig, axes = plt.subplots(ncols=2) num = 5 xy = np.random.random((num, 2)) lines = [] for i in range(num): line, = axes[0].plot((i + 1) * np.arange(10)) lines.append(line) points = [] for x, y in xy: point, = axes[1].plot([x], [y], linestyle="none", marker="o") points.append(point) cursor = mplcursors.cursor(points + lines, highlight=True) pairs = dict(zip(points, lines)) pairs.update(zip(lines, points)) @cursor.connect("add") def on_add(sel): sel.extras.append(cursor.add_highlight(pairs[sel.artist])) plt.show() if __name__ == "__main__": main() mplcursors-0.5.2/examples/scatter.py000066400000000000000000000011271431464644700176020ustar00rootroot00000000000000""" Scatter plots are highlighted point-by-point. ============================================= ... as opposed to lines with a ``"."`` style, which have the same appearance, but are highlighted as a whole. """ import numpy as np import matplotlib.pyplot as plt import mplcursors x, y, z = np.random.random((3, 10)) fig, axs = plt.subplots(3) fig.suptitle("Highlighting affects individual points\n" "only in scatter plots (top two axes)") axs[0].scatter(x, y, c=z, s=100 * np.random.random(10)) axs[1].scatter(x, y) axs[2].plot(x, y, "o") mplcursors.cursor(highlight=True) plt.show() mplcursors-0.5.2/examples/step.py000066400000000000000000000012201431464644700171020ustar00rootroot00000000000000""" Step plots ========== A selection on a step plot holds precise information on the x and y position in the ``sel.index`` attribute. """ from matplotlib import pyplot as plt import mplcursors import numpy as np fig, axs = plt.subplots(4, sharex=True, sharey=True) np.random.seed(42) xs = np.arange(5) ys = np.random.rand(5) axs[0].plot(xs, ys, "-o") axs[1].plot(xs, ys, "-o", drawstyle="steps-pre") axs[2].plot(xs, ys, "-o", drawstyle="steps-mid") axs[3].plot(xs, ys, "-o", drawstyle="steps-post") for ax in axs: ax.label_outer() mplcursors.cursor().connect( "add", lambda sel: sel.annotation.set_text(format(sel.index, ".2f"))) plt.show() mplcursors-0.5.2/lib/000077500000000000000000000000001431464644700145125ustar00rootroot00000000000000mplcursors-0.5.2/lib/mplcursors/000077500000000000000000000000001431464644700167235ustar00rootroot00000000000000mplcursors-0.5.2/lib/mplcursors/.gitignore000066400000000000000000000000141431464644700207060ustar00rootroot00000000000000_version.py mplcursors-0.5.2/lib/mplcursors/__init__.py000066400000000000000000000006001431464644700210300ustar00rootroot00000000000000try: from ._version import version as __version__ except ImportError: __version__ = "(unknown version)" from ._mplcursors import Cursor, HoverMode, cursor from ._pick_info import ( Selection, compute_pick, get_ann_text, move, make_highlight) __all__ = ["Cursor", "HoverMode", "cursor", "Selection", "compute_pick", "get_ann_text", "move", "make_highlight"] mplcursors-0.5.2/lib/mplcursors/_mplcursors.py000066400000000000000000000707231431464644700216560ustar00rootroot00000000000000from collections.abc import Iterable from contextlib import suppress import copy from enum import IntEnum from functools import partial import sys import weakref from weakref import WeakKeyDictionary, WeakSet from matplotlib.axes import Axes from matplotlib.container import Container from matplotlib.figure import Figure import numpy as np from . import _pick_info _default_bindings = dict( select=1, deselect=3, left="shift+left", right="shift+right", up="shift+up", down="shift+down", toggle_enabled="e", toggle_visible="v", ) _default_annotation_kwargs = dict( bbox=dict( boxstyle="round,pad=.5", fc="yellow", alpha=.5, ec="k", ), arrowprops=dict( arrowstyle="->", connectionstyle="arc3", shrinkB=0, ec="k", ), ) _default_annotation_positions = [ dict(position=(-15, 15), anncoords="offset points", horizontalalignment="right", verticalalignment="bottom"), dict(position=(15, 15), anncoords="offset points", horizontalalignment="left", verticalalignment="bottom"), dict(position=(15, -15), anncoords="offset points", horizontalalignment="left", verticalalignment="top"), dict(position=(-15, -15), anncoords="offset points", horizontalalignment="right", verticalalignment="top"), ] _default_highlight_kwargs = dict( # Only the kwargs corresponding to properties of the artist will be passed. # Line2D. color="yellow", markeredgecolor="yellow", linewidth=3, markeredgewidth=3, # PathCollection. facecolor="yellow", edgecolor="yellow", ) class _MarkedStr(str): """A string subclass solely for marking purposes.""" def _mouse_event_matches(event, spec): """ Return whether a mouse event "matches" an event spec, which is either a single mouse button, or a mapping matched against ``vars(event)``, e.g. ``{"button": 1, "key": "control"}``. """ if isinstance(spec, int): spec = {"button": spec} return all(getattr(event, k) == v for k, v in spec.items()) def _get_rounded_intersection_area(bbox_1, bbox_2): """Compute the intersection area between two bboxes rounded to 8 digits.""" # The rounding allows sorting areas without floating point issues. bbox = bbox_1.intersection(bbox_1, bbox_2) return round(bbox.width * bbox.height, 8) if bbox else 0 def _iter_axes_subartists(ax): r"""Yield all child `Artist`\s (*not* `Container`\s) of *ax*.""" yield from ax.collections yield from ax.images yield from ax.lines yield from ax.patches yield from ax.texts def _is_alive(artist): """Check whether *artist* is still present on its parent axes.""" return bool(artist and artist.axes and (artist.container in artist.axes.containers if isinstance(artist, _pick_info.ContainerArtist) else artist in _iter_axes_subartists(artist.axes))) def _reassigned_axes_event(event, ax): """Reassign *event* to *ax*.""" event = copy.copy(event) event.xdata, event.ydata = ( ax.transData.inverted().transform((event.x, event.y))) return event class HoverMode(IntEnum): NoHover, Persistent, Transient = range(3) class Cursor: """ A cursor for selecting Matplotlib artists. Attributes ---------- bindings : dict See the *bindings* keyword argument to the constructor. annotation_kwargs : dict See the *annotation_kwargs* keyword argument to the constructor. annotation_positions : dict See the *annotation_positions* keyword argument to the constructor. highlight_kwargs : dict See the *highlight_kwargs* keyword argument to the constructor. """ _keep_alive = WeakKeyDictionary() def __init__(self, artists, *, multiple=False, highlight=False, hover=False, bindings=None, annotation_kwargs=None, annotation_positions=None, highlight_kwargs=None): """ Construct a cursor. Parameters ---------- artists : List[Artist] A list of artists that can be selected by this cursor. multiple : bool, default: False Whether multiple artists can be "on" at the same time. If on, cursor dragging is disabled (so that one does not end up with many cursors on top of one another). highlight : bool, default: False Whether to also highlight the selected artist. If so, "highlighter" artists will be placed as the first item in the :attr:`extras` attribute of the `Selection`. hover : `HoverMode`, default: False Whether to select artists upon hovering instead of by clicking. (Hovering over an artist while a button is pressed will not trigger a selection; right clicking on an annotation will still remove it.) Possible values are - False, alias `HoverMode.NoHover`: hovering is inactive. - True, alias `HoverMode.Persistent`: hovering is active; annotations remain in place even after the mouse moves away from the artist (until another artist is selected, if *multiple* is False). - 2, alias `HoverMode.Transient`: hovering is active; annotations are removed as soon as the mouse moves away from the artist. bindings : dict, optional A mapping of actions to button and keybindings. Valid keys are: ================ ================================================== 'select' mouse button to select an artist (default: 1) 'deselect' mouse button to deselect an artist (default: 3) 'left' move to the previous point in the selected path, or to the left in the selected image (default: shift+left) 'right' move to the next point in the selected path, or to the right in the selected image (default: shift+right) 'up' move up in the selected image (default: shift+up) 'down' move down in the selected image (default: shift+down) 'toggle_enabled' toggle whether the cursor is active (default: e) 'toggle_visible' toggle default cursor visibility and apply it to all cursors (default: v) ================ ================================================== Missing entries will be set to the defaults. In order to not assign any binding to an action, set it to ``None``. Modifier keys (or other event properties) can be set for mouse button bindings by passing them as e.g. ``{"button": 1, "key": "control"}``. annotation_kwargs : dict, default: {} Keyword argments passed to the `annotate ` call. annotation_positions : List[dict], optional List of positions tried by the annotation positioning algorithm. The default is to try four positions, 15 points to the NW, NE, SE, and SW from the selected point; annotations that stay within the axes are preferred. highlight_kwargs : dict, default: {} Keyword arguments used to create a highlighted artist. """ artists = [*artists] # Be careful with GC. self._artists = [weakref.ref(artist) for artist in artists] for artist in artists: type(self)._keep_alive.setdefault(artist, set()).add(self) self._multiple = multiple self._highlight = highlight self._visible = True self._enabled = True self._selections = [] self._selection_stack = [] self._last_auto_position = None self._callbacks = {"add": [], "remove": []} self._hover = hover self._suppressed_events = WeakSet() connect_pairs = [ ("pick_event", self._on_pick), ("key_press_event", self._on_key_press), ] if hover: connect_pairs += [ ("motion_notify_event", self._on_hover_motion_notify), ("button_press_event", self._on_hover_button_press), ] else: connect_pairs += [ ("button_press_event", self._on_nonhover_button_press), ] if not self._multiple: connect_pairs.append( ("motion_notify_event", self._on_nonhover_button_press)) self._disconnectors = [ partial(canvas.mpl_disconnect, canvas.mpl_connect(*pair)) for pair in connect_pairs for canvas in {artist.figure.canvas for artist in artists}] bindings = {**_default_bindings, **(bindings if bindings is not None else {})} unknown_bindings = {*bindings} - {*_default_bindings} if unknown_bindings: raise ValueError("Unknown binding(s): {}".format( ", ".join(sorted(unknown_bindings)))) bindings_items = list(bindings.items()) for i in range(len(bindings)): action, key = bindings_items[i] for j in range(i): other_action, other_key = bindings_items[j] if key == other_key: raise ValueError( f"Duplicate bindings: {key} is used for " f"{other_action} and for {action}") self.bindings = bindings self.annotation_kwargs = ( annotation_kwargs if annotation_kwargs is not None else copy.deepcopy(_default_annotation_kwargs)) self.annotation_positions = ( annotation_positions if annotation_positions is not None else copy.deepcopy(_default_annotation_positions)) self.highlight_kwargs = ( highlight_kwargs if highlight_kwargs is not None else copy.deepcopy(_default_highlight_kwargs)) @property def artists(self): """The tuple of selectable artists.""" # Work around matplotlib/matplotlib#6982: `cla()` does not clear # `.axes`. return tuple(filter(_is_alive, (ref() for ref in self._artists))) @property def enabled(self): """Whether clicks are registered for picking and unpicking events.""" return self._enabled @enabled.setter def enabled(self, value): self._enabled = value @property def selections(self): r"""The tuple of current `Selection`\s.""" for sel in self._selections: if sel.annotation.axes is None: raise RuntimeError("Annotation unexpectedly removed; " "use 'cursor.remove_selection' instead") return tuple(self._selections) @property def visible(self): """ Whether selections are visible by default. Setting this property also updates the visibility status of current selections. """ return self._visible @visible.setter def visible(self, value): self._visible = value for sel in self.selections: sel.annotation.set_visible(value) sel.annotation.figure.canvas.draw_idle() def _get_figure(self, aoc): """Return the parent figure of artist-or-container *aoc*.""" if isinstance(aoc, Container): try: ca, = {artist for artist in (ref() for ref in self._artists) if isinstance(artist, _pick_info.ContainerArtist) and artist.container is aoc} except ValueError: raise ValueError(f"Cannot find parent figure of {aoc}") return ca.figure else: return aoc.figure def _get_axes(self, aoc): """Return the parent axes of artist-or-container *aoc*.""" if isinstance(aoc, Container): try: ca, = {artist for artist in (ref() for ref in self._artists) if isinstance(artist, _pick_info.ContainerArtist) and artist.container is aoc} except ValueError: raise ValueError(f"Cannot find parent axes of {aoc}") return ca.axes else: return aoc.axes def add_selection(self, pi): """ Create an annotation for a `Selection` and register it. Returns a new `Selection`, that has been registered by the `Cursor`, with the added annotation set in the :attr:`annotation` field and, if applicable, the highlighting artist in the :attr:`extras` field. Emits the ``"add"`` event with the new `Selection` as argument. When the event is emitted, the position of the annotation is temporarily set to ``(nan, nan)``; if this position is not explicitly set by a callback, then a suitable position will be automatically computed. Likewise, if the text alignment is not explicitly set but the position is, then a suitable alignment will be automatically computed. """ # pi: "pick_info", i.e. an incomplete selection. # Pre-fetch the figure and axes, as callbacks may actually unset them. figure = self._get_figure(pi.artist) axes = self._get_axes(pi.artist) get_cached_renderer = ( figure.canvas.get_renderer if hasattr(figure.canvas, "get_renderer") else axes.get_renderer_cache) # matplotlib <3.6. renderer = get_cached_renderer() if renderer is None: figure.canvas.draw() # Needed below anyways. renderer = get_cached_renderer() ann = axes.annotate( _pick_info.get_ann_text(*pi), xy=pi.target, xytext=(np.nan, np.nan), horizontalalignment=_MarkedStr("center"), verticalalignment=_MarkedStr("center"), visible=self.visible, zorder=np.inf, **self.annotation_kwargs) # Move the Annotation's ownership from the Axes to the Figure, so that # it gets drawn even above twinned axes. But ann.axes must stay set, # so that e.g. unit converters get correctly applied. ann.remove() ann.axes = axes figure.add_artist(ann) ann.draggable(use_blit=not self._multiple) extras = [] if self._highlight: hl = self.add_highlight(*pi) if hl: extras.append(hl) sel = pi._replace(annotation=ann, extras=extras) self._selections.append(sel) self._selection_stack.append(sel) for cb in self._callbacks["add"]: cb(sel) # Check that `ann.axes` is still set, as callbacks may have removed the # annotation. if ann.axes and ann.xyann == (np.nan, np.nan): fig_bbox = figure.get_window_extent() ax_bbox = axes.get_window_extent() overlaps = [] for idx, annotation_position in enumerate( self.annotation_positions): ann.set(**annotation_position) # Work around matplotlib/matplotlib#7614: position update is # missing. ann.update_positions(renderer) bbox = ann.get_window_extent(renderer) overlaps.append( (_get_rounded_intersection_area(fig_bbox, bbox), _get_rounded_intersection_area(ax_bbox, bbox), # Avoid needlessly jumping around by breaking ties using # the last used position as default. idx == self._last_auto_position)) auto_position = max(range(len(overlaps)), key=overlaps.__getitem__) ann.set(**self.annotation_positions[auto_position]) self._last_auto_position = auto_position else: if isinstance(ann.get_horizontalalignment(), _MarkedStr): ann.set_horizontalalignment( {-1: "right", 0: "center", 1: "left"}[ np.sign(np.nan_to_num(ann.xyann[0]))]) if isinstance(ann.get_verticalalignment(), _MarkedStr): ann.set_verticalalignment( {-1: "top", 0: "center", 1: "bottom"}[ np.sign(np.nan_to_num(ann.xyann[1]))]) if (extras or len(self.selections) > 1 and not self._multiple or not figure.canvas.supports_blit): # Either: # - there may be more things to draw, or # - annotation removal will make a full redraw necessary, or # - blitting is not (yet) supported. figure.canvas.draw_idle() elif ann.axes: # Fast path, only needed if the annotation has not been immediately # removed. ann.draw(renderer) figure.canvas.blit() # Removal comes after addition so that the fast blitting path works. if not self._multiple: for sel in self.selections[:-1]: self.remove_selection(sel) return sel def add_highlight(self, artist, *args, **kwargs): """ Create, add, and return a highlighting artist. This method is should be called with an "unpacked" `Selection`, possibly with some fields set to None. It is up to the caller to register the artist with the proper `Selection` (by calling ``sel.extras.append`` on the result of this method) in order to ensure cleanup upon deselection. """ hl = _pick_info.make_highlight( artist, *args, **{"highlight_kwargs": self.highlight_kwargs, **kwargs}) if hl: artist.axes.add_artist(hl) return hl def connect(self, event, func=None): """ Connect a callback to a `Cursor` event; return the callback. Two events can be connected to: - callbacks connected to the ``"add"`` event are called when a `Selection` is added, with that selection as only argument; - callbacks connected to the ``"remove"`` event are called when a `Selection` is removed, with that selection as only argument. This method can also be used as a decorator:: @cursor.connect("add") def on_add(sel): ... Examples of callbacks:: # Change the annotation text and alignment: lambda sel: sel.annotation.set( text=sel.artist.get_label(), # or use e.g. sel.index ha="center", va="bottom") # Make label non-draggable: lambda sel: sel.draggable(False) Note that when a single event causes both the removal of an "old" selection and the addition of a "new" one (typically, clicking on an artist when another one is selected, or hovering -- both assuming that ``multiple=False``), the "add" callback is called *first*. This allows it, in particular, to "cancel" the addition (by immediately removing the "new" selection) and thus avoid removing the "old" selection. However, this call order may change in a future release. """ if event not in self._callbacks: raise ValueError(f"{event!r} is not a valid cursor event") if func is None: return partial(self.connect, event) self._callbacks[event].append(func) return func def disconnect(self, event, cb): """ Disconnect a previously connected callback. If a callback is connected multiple times, only one connection is removed. """ try: self._callbacks[event].remove(cb) except KeyError: raise ValueError(f"{event!r} is not a valid cursor event") except ValueError: raise ValueError(f"Callback {cb} is not registered to {event}") def remove(self): """ Remove a cursor. Remove all `Selection`\\s, disconnect all callbacks, and allow the cursor to be garbage collected. """ for disconnectors in self._disconnectors: disconnectors() for sel in self.selections: self.remove_selection(sel) for s in type(self)._keep_alive.values(): with suppress(KeyError): s.remove(self) def _on_pick(self, event): # Avoid creating a new annotation when dragging a preexisting # annotation (if multiple = True). To do so, rely on the fact that # pick_events (which are used to implement dragging) trigger first (via # Figure's button_press_event, which is registered first); when one of # our annotations is picked, registed the corresponding mouse event as # "suppressed". This can be done via a WeakSet as Matplotlib will keep # the event alive while being propagated through the callbacks. # Additionally, also rely on this mechanism to update the "current" # selection. for sel in self._selections: if event.artist is sel.annotation: self._suppressed_events.add(event.mouseevent) self._selection_stack.remove(sel) self._selection_stack.append(sel) break def _on_nonhover_button_press(self, event): if _mouse_event_matches(event, self.bindings["select"]): self._on_select_event(event) if _mouse_event_matches(event, self.bindings["deselect"]): self._on_deselect_event(event) def _on_hover_motion_notify(self, event): if event.button is None: # Filter away events where the mouse is pressed, in particular to # avoid conflicts between hover and draggable. self._on_select_event(event) def _on_hover_button_press(self, event): if _mouse_event_matches(event, self.bindings["deselect"]): # Still allow removing the annotation by right clicking. self._on_deselect_event(event) def _filter_mouse_event(self, event): # Accept the event iff we are enabled, and either # - no other widget is active, and this is not the second click of a # double click (to prevent double selection), or # - another widget is active, and this is a double click (to bypass # the widget lock), or # - hovering is active (in which case this is a motion_notify_event # anyways). return (self.enabled and (event.canvas.widgetlock.locked() == event.dblclick or self._hover)) def _on_select_event(self, event): if (not self._filter_mouse_event(event) # See _on_pick. (We only suppress selects, not deselects.) or event in self._suppressed_events): return # Work around lack of support for twinned axes. per_axes_event = {ax: _reassigned_axes_event(event, ax) for ax in {artist.axes for artist in self.artists}} pis = [] for artist in self.artists: if (artist.axes is None # Removed or figure-level artist. or event.canvas is not artist.figure.canvas or not artist.get_visible() or not artist.axes.contains(event)[0]): # Cropped by axes. continue pi = _pick_info.compute_pick(artist, per_axes_event[artist.axes]) if pi: pis.append(pi) # The any() check avoids picking an already selected artist at the same # point, as likely the user is just dragging it. We check this here # rather than not adding the pick_info to pis at all, because in # transient hover mode, selections should be cleared out only when no # candidate picks (including such duplicates) exist at all. pi = min((pi for pi in pis if not any((pi.artist, tuple(pi.target)) == (other.artist, tuple(other.target)) for other in self._selections)), key=lambda pi: pi.dist, default=None) if pi: self.add_selection(pi) elif not pis and self._hover == HoverMode.Transient: for sel in self.selections: if event.canvas is sel.annotation.figure.canvas: self.remove_selection(sel) def _on_deselect_event(self, event): if not self._filter_mouse_event(event): return for sel in self.selections[::-1]: # LIFO. ann = sel.annotation if event.canvas is not ann.figure.canvas: continue contained, _ = ann.contains(event) if contained: self.remove_selection(sel) break def _on_key_press(self, event): if event.key == self.bindings["toggle_enabled"]: self.enabled = not self.enabled elif event.key == self.bindings["toggle_visible"]: self.visible = not self.visible if not self._selections or not self.enabled: return sel = self._selection_stack[-1] for key in ["left", "right", "up", "down"]: if event.key == self.bindings[key]: self.remove_selection(sel) self.add_selection(_pick_info.move(*sel, key=key)) break def remove_selection(self, sel): """Remove a `Selection`.""" self._selections.remove(sel) self._selection_stack.remove(sel) # .figure will be unset so we save them first. figures = {artist.figure for artist in [sel.annotation] + sel.extras} # ValueError is raised if the artist has already been removed. with suppress(ValueError): sel.annotation.remove() for artist in sel.extras: with suppress(ValueError): artist.remove() for cb in self._callbacks["remove"]: cb(sel) for figure in figures: figure.canvas.draw_idle() def cursor(pickables=None, **kwargs): """ Create a `Cursor` for a list of artists, containers, and axes. Parameters ---------- pickables : Optional[List[Union[Artist, Container, Axes, Figure]]] All artists and containers in the list or on any of the axes or figures passed in the list are selectable by the constructed `Cursor`. Defaults to all artists and containers on any of the figures that :mod:`~matplotlib.pyplot` is tracking. Note that the latter will only work when relying on pyplot, not when figures are directly instantiated (e.g., when manually embedding Matplotlib in a GUI toolkit). **kwargs Keyword arguments are passed to the `Cursor` constructor. """ # Explicit check to avoid a confusing # "TypeError: Cursor.__init__() got multiple values for argument 'artists'" if "artists" in kwargs: raise TypeError( "cursor() got an unexpected keyword argument 'artists'") if pickables is None: # Do not import pyplot ourselves to avoid forcing the backend. plt = sys.modules.get("matplotlib.pyplot") pickables = [ plt.figure(num) for num in plt.get_fignums()] if plt else [] elif (isinstance(pickables, Container) or not isinstance(pickables, Iterable)): pickables = [pickables] def iter_unpack_figures(pickables): for entry in pickables: if isinstance(entry, Figure): yield from entry.axes else: yield entry def iter_unpack_axes(pickables): for entry in pickables: if isinstance(entry, Axes): yield from _iter_axes_subartists(entry) containers.extend(entry.containers) elif isinstance(entry, Container): containers.append(entry) else: yield entry containers = [] artists = [*iter_unpack_axes(iter_unpack_figures(pickables))] for container in containers: contained = [*filter(None, container.get_children())] for artist in contained: with suppress(ValueError): artists.remove(artist) if contained: artists.append(_pick_info.ContainerArtist(container)) return Cursor(artists, **kwargs) mplcursors-0.5.2/lib/mplcursors/_pick_info.py000066400000000000000000000661441431464644700214100ustar00rootroot00000000000000# Unsupported Artist classes: subclasses of AxesImage, QuadMesh (upstream could # have a `format_coord`-like method); PolyCollection (picking is not well # defined). from collections import namedtuple from contextlib import suppress import copy import functools import inspect from inspect import Signature import itertools from numbers import Integral import re import warnings from weakref import WeakSet from matplotlib import cbook from matplotlib.axes import Axes from matplotlib.backend_bases import RendererBase from matplotlib.collections import ( LineCollection, PatchCollection, PathCollection) from matplotlib.container import BarContainer, ErrorbarContainer, StemContainer from matplotlib.figure import Figure from matplotlib.image import AxesImage from matplotlib.lines import Line2D from matplotlib.patches import Patch, PathPatch, Polygon, Rectangle from matplotlib.quiver import Barbs, Quiver from matplotlib.text import Text from matplotlib.transforms import Affine2D import numpy as np PATCH_PICKRADIUS = 5 # FIXME Patches do not provide `pickradius`. def _register_scatter(): """ Patch `PathCollection` and `scatter` to register their return values. This registration allows us to distinguish `PathCollection`s created by `Axes.scatter`, which should use point-like picking, from others, which should use path-like picking. The former is more common, so we store the latter instead; this also lets us guess the type better if this module is imported late. """ @functools.wraps(PathCollection.__init__) def __init__(self, *args, **kwargs): _nonscatter_pathcollections.add(self) return __init__.__wrapped__(self, *args, **kwargs) PathCollection.__init__ = __init__ @functools.wraps(Axes.scatter) def scatter(*args, **kwargs): paths = scatter.__wrapped__(*args, **kwargs) with suppress(KeyError): _nonscatter_pathcollections.remove(paths) return paths Axes.scatter = scatter _nonscatter_pathcollections = WeakSet() _register_scatter() def _is_scatter(artist): return (isinstance(artist, PathCollection) and artist not in _nonscatter_pathcollections) def _artist_in_container(container): return next(filter(None, container.get_children())) class ContainerArtist: """Workaround to make containers behave more like artists.""" def __init__(self, container): self.container = container # Guaranteed to be nonempty. # We can't weakref the Container (which subclasses tuple), so # we instead create a reference cycle between the Container and # the ContainerArtist; as no one else strongly references the # ContainerArtist, it will get GC'd whenever the Container is. vars(container).setdefault( f"_{__class__.__name__}__keep_alive", []).append(self) def __str__(self): return f"<{type(self).__name__}({self.container})>" def __repr__(self): return f"<{type(self).__name__}({self.container!r})>" figure = property(lambda self: _artist_in_container(self.container).figure) axes = property(lambda self: _artist_in_container(self.container).axes) def get_visible(self): return True # For lack of anything better. Selection = namedtuple( "Selection", "artist target_ index dist annotation extras") Selection.__doc__ = """ A selection. Although this class is implemented as a namedtuple (to simplify the dispatching of `compute_pick`, `get_ann_text`, and `make_highlight`), only the field names should be considered stable API. The number and order of fields are subject to change with no notice. """ # Override equality to identity: Selections should be considered immutable # (with mutable fields though) and we don't want to trigger casts of array # equality checks to booleans. We don't need to override comparisons because # artists are already non-comparable. Selection.__eq__ = lambda self, other: self is other Selection.__ne__ = lambda self, other: self is not other Selection.artist.__doc__ = ( "The selected artist.") Selection.target_.__doc__ = """ The point picked within the artist, in data coordinates. :meta private: """ Selection.index.__doc__ = ( "The index of the selected point, within the artist data.") Selection.dist.__doc__ = ( "The distance from the click to the target, in pixels.") Selection.annotation.__doc__ = ( "The instantiated `matplotlib.text.Annotation`.") Selection.extras.__doc__ = ( "An additional list of artists (e.g., highlighters) that will be cleared " "at the same time as the annotation.") class _Target(np.ndarray): def __new__(cls, sel): obj = np.asarray(sel.target_).view(cls) obj._sel = sel return obj @property def index(self): warnings.warn( "Selection.target.index is deprecated and will be removed in the " "future; use Selection.index instead.") return self._sel.index Selection.target = property( _Target, doc="The point picked within the artist, in data coordinates.") @functools.singledispatch def compute_pick(artist, event): """ Find whether *artist* has been picked by *event*. If it has, return the appropriate `Selection`; otherwise return ``None``. This is a single-dispatch function; implementations for various artist classes follow. """ warnings.warn(f"Pick support for {type(artist).__name__} is missing.") class Index: def __init__(self, i, x, y): self.int = i self.x = x self.y = y def __floor__(self): return self.int def __ceil__(self): return self.int if max(self.x, self.y) == 0 else self.int + 1 # numpy<1.17 backcompat. floor = __floor__ ceil = __ceil__ def __format__(self, fmt): return f"{self.int}.(x={self.x:{fmt}}, y={self.y:{fmt}})" def __str__(self): return format(self, "") @classmethod def pre_index(cls, n_pts, index): i, frac = divmod(index, 1) i, odd = divmod(i, 2) x, y = (0, frac) if not odd else (frac, 1) return cls(i, x, y) @classmethod def post_index(cls, n_pts, index): i, frac = divmod(index, 1) i, odd = divmod(i, 2) x, y = (frac, 0) if not odd else (1, frac) return cls(i, x, y) @classmethod def mid_index(cls, n_pts, index): i, frac = divmod(index, 1) if i == 0: frac = .5 + frac / 2 elif i == 2 * n_pts - 2: # One less line than points. frac = frac / 2 quot, odd = divmod(i, 2) if not odd: if frac < .5: i = quot - 1 x, y = frac + .5, 1 else: i = quot x, y = frac - .5, 0 else: i = quot x, y = .5, frac return cls(i, x, y) def _compute_projection_pick(artist, path, xy): """ Project *xy* on *path* to obtain a `Selection` for *artist*. *path* is first transformed to screen coordinates using the artist transform, and the target of the returned `Selection` is transformed back to data coordinates using the artist *axes* inverse transform. The `Selection` `index` is returned as a float. This function returns ``None`` for degenerate inputs. The caller is responsible for converting the index to the proper class if needed. """ transform = artist.get_transform().frozen() tpath = (path.cleaned(transform) if transform.is_affine # `cleaned` only handles affine transforms. else transform.transform_path(path).cleaned()) # `cleaned` should return a path where the first element is `MOVETO`, the # following are `LINETO` or `CLOSEPOLY`, and the last one is `STOP`, i.e. # codes = path.codes # assert (codes[0], codes[-1]) == (path.MOVETO, path.STOP) # assert np.in1d(codes[1:-1], [path.LINETO, path.CLOSEPOLY]).all() vertices = tpath.vertices[:-1] codes = tpath.codes[:-1] vertices[codes == tpath.CLOSEPOLY] = vertices[0] # Unit vectors for each segment. us = vertices[1:] - vertices[:-1] ls = np.hypot(*us.T) with np.errstate(invalid="ignore"): # Results in 0/0 for repeated consecutive points. us /= ls[:, None] # Vectors from each vertex to the event (overwritten below). vs = xy - vertices[:-1] # Clipped dot products -- `einsum` cannot be done in place, `clip` can. # `clip` can trigger invalid comparisons if there are nan points. with np.errstate(invalid="ignore"): dot = np.clip(np.einsum("ij,ij->i", vs, us), 0, ls, out=vs[:, 0]) # Projections. projs = vertices[:-1] + dot[:, None] * us ds = np.hypot(*(xy - projs).T, out=vs[:, 1]) try: argmin = np.nanargmin(ds) dmin = ds[argmin] except (ValueError, IndexError): # See above re: exceptions caught. return else: target = artist.axes.transData.inverted().transform(projs[argmin]) index = ((argmin + dot[argmin] / ls[argmin]) / (path._interpolation_steps / tpath._interpolation_steps)) return Selection(artist, target, index, dmin, None, None) def _untransform(orig_xy, screen_xy, ax): """ Return data coordinates to place an annotation at screen coordinates *screen_xy* in axes *ax*. *orig_xy* are the "original" coordinates as stored by the artist; they are transformed to *screen_xy* by whatever transform the artist uses. If the artist uses ``ax.transData``, just return *orig_xy*; else, apply ``ax.transData.inverse()`` to *screen_xy*. (The first case is more accurate than always applying ``ax.transData.inverse()``.) """ tr_xy = ax.transData.transform(orig_xy) return ( orig_xy if ((tr_xy == screen_xy) | np.isnan(tr_xy) & np.isnan(screen_xy)).all() else ax.transData.inverted().transform(screen_xy)) @compute_pick.register(Line2D) def _(artist, event): # No need to call `line.contains` as we're going to redo the work anyways # (also see matplotlib/matplotlib#6645, though that's fixed in mpl2.1). # Always work in screen coordinates, as this is how we need to compute # distances. Note that the artist transform may be different from the axes # transform (e.g., for axvline). xy = event.x, event.y data_xy = artist.get_xydata() data_screen_xy = artist.get_transform().transform(data_xy) sels = [] # If markers are visible, find the closest vertex. if artist.get_marker() not in ["None", "none", " ", "", None]: ds = np.hypot(*(xy - data_screen_xy).T) try: argmin = np.nanargmin(ds) except ValueError: # Raised by nanargmin([nan]). pass else: target = _untransform( # More precise than transforming back. data_xy[argmin], data_screen_xy[argmin], artist.axes) sels.append( Selection(artist, target, argmin, ds[argmin], None, None)) # If lines are visible, find the closest projection. if (artist.get_linestyle() not in ["None", "none", " ", "", None] and len(artist.get_xydata()) > 1): sel = _compute_projection_pick(artist, artist.get_path(), xy) if sel is not None: sel = sel._replace(index={ "_draw_lines": lambda _, index: index, "_draw_steps_pre": Index.pre_index, "_draw_steps_mid": Index.mid_index, "_draw_steps_post": Index.post_index}[ Line2D.drawStyles[artist.get_drawstyle()]]( len(data_xy), sel.index)) sels.append(sel) sel = min(sels, key=lambda sel: sel.dist, default=None) return sel if sel and sel.dist < artist.get_pickradius() else None @compute_pick.register(PathPatch) @compute_pick.register(Polygon) @compute_pick.register(Rectangle) def _(artist, event): sel = _compute_projection_pick( artist, artist.get_path(), (event.x, event.y)) if sel and sel.dist < PATCH_PICKRADIUS: return sel @compute_pick.register(LineCollection) @compute_pick.register(PatchCollection) @compute_pick.register(PathCollection) def _(artist, event): offsets = artist.get_offsets() paths = artist.get_paths() if _is_scatter(artist): # Use the C implementation to prune the list of segments -- but only # for scatter plots as that implementation is inconsistent with Line2D # for segment-like collections (matplotlib/matplotlib#17279). contains, info = artist.contains(event) if not contains: return inds = info["ind"] offsets = artist.get_offsets()[inds] offsets_screen = artist.get_offset_transform().transform(offsets) ds = np.hypot(*(offsets_screen - [event.x, event.y]).T) argmin = ds.argmin() target = _untransform( offsets[argmin], offsets_screen[argmin], artist.axes) return Selection(artist, target, inds[argmin], ds[argmin], None, None) elif len(paths) and len(offsets): # Note that this won't select implicitly closed paths. sels = [*filter(None, [ _compute_projection_pick( artist, Affine2D().translate(*offsets[ind % len(offsets)]) .transform_path(paths[ind % len(paths)]), (event.x, event.y)) for ind in range(max(len(offsets), len(paths)))])] if not sels: return None idx = min(range(len(sels)), key=lambda idx: sels[idx].dist) sel = sels[idx] if sel.dist >= artist.get_pickradius(): return None return sel._replace(artist=artist, index=(idx, sel.index)) @compute_pick.register(AxesImage) def _(artist, event): if type(artist) != AxesImage: # Skip and warn on subclasses (`NonUniformImage`, `PcolorImage`) as # they do not implement `contains` correctly. Even if they did, they # would not support moving as we do not know where a given index maps # back physically. return compute_pick.dispatch(object)(artist, event) contains, _ = artist.contains(event) if not contains: return ns = np.asarray(artist.get_array().shape[:2])[::-1] # (y, x) -> (x, y) xy = np.array([event.xdata, event.ydata]) xmin, xmax, ymin, ymax = artist.get_extent() # Handling of "upper" origin copied from AxesImage.get_cursor_data. if artist.origin == "upper": ymin, ymax = ymax, ymin low, high = np.array([[xmin, ymin], [xmax, ymax]]) idxs = ((xy - low) / (high - low) * ns).astype(int)[::-1] return Selection(artist, xy, tuple(idxs), 0, None, None) @compute_pick.register(Barbs) @compute_pick.register(Quiver) def _(artist, event): offsets = artist.get_offsets() offsets_screen = artist.get_offset_transform().transform(offsets) ds = np.hypot(*(offsets_screen - [event.x, event.y]).T) argmin = np.nanargmin(ds) if ds[argmin] < artist.get_pickradius(): target = _untransform( offsets[argmin], offsets_screen[argmin], artist.axes) return Selection(artist, target, argmin, ds[argmin], None, None) else: return None @compute_pick.register(Text) def _(artist, event): return @compute_pick.register(ContainerArtist) def _(artist, event): return compute_pick(artist.container, event) @compute_pick.register(BarContainer) def _(container, event): try: (idx, patch), = { (idx, patch) for idx, patch in enumerate(container.patches) if patch.contains(event)[0]} except ValueError: return target = [event.xdata, event.ydata] if patch.sticky_edges.x: target[0], = ( x for x in [patch.get_x(), patch.get_x() + patch.get_width()] if x not in patch.sticky_edges.x) if patch.sticky_edges.y: target[1], = ( y for y in [patch.get_y(), patch.get_y() + patch.get_height()] if y not in patch.sticky_edges.y) return Selection(container, target, idx, 0, None, None) @compute_pick.register(ErrorbarContainer) def _(container, event): data_line, cap_lines, err_lcs = container sel_data = compute_pick(data_line, event) if data_line else None sel_err = min( filter(None, (compute_pick(err_lc, event) for err_lc in err_lcs)), key=lambda sel: sel.dist, default=None) if (sel_data and sel_data.dist < getattr(sel_err, "dist", np.inf)): return sel_data elif sel_err: idx, _ = sel_err.index if data_line: target = data_line.get_xydata()[idx] else: # We can't guess the original data in that case! return return Selection(container, target, idx, 0, None, None) else: return @compute_pick.register(StemContainer) def _(container, event): sel = compute_pick(container.markerline, event) if sel: return sel if not isinstance(container.stemlines, LineCollection): warnings.warn("Only stem plots created with use_line_collection=True " "are supported.") return sel = compute_pick(container.stemlines, event) if sel: idx, _ = sel.index target = container.stemlines.get_segments()[idx][-1] return Selection(container, target, sel.index, 0, None, None) def _call_with_selection(func=None, *, argname="artist"): """Decorator that passes a `Selection` built from the non-kwonly args.""" if func is None: return functools.partial(_call_with_selection, argname=argname) wrapped_kwonly_params = [ param for param in inspect.signature(func).parameters.values() if param.kind == param.KEYWORD_ONLY] sel_sig = inspect.signature(Selection) default_sel_sig = sel_sig.replace( parameters=[param.replace(default=None) if param.default is param.empty else param for param in sel_sig.parameters.values()]) @functools.wraps(func) def wrapper(*args, **kwargs): extra_kw = {param.name: kwargs.pop(param.name) for param in wrapped_kwonly_params if param.name in kwargs} ba = default_sel_sig.bind(*args, **kwargs) ba.apply_defaults() sel = Selection(*ba.args, **ba.kwargs) return func(sel, **extra_kw) params = [*sel_sig.parameters.values(), *wrapped_kwonly_params] params[0] = params[0].replace(name=argname) wrapper.__signature__ = Signature(params) return wrapper def _format_coord_unspaced(ax, xy): # Un-space-pad, remove empty coordinates from the output of # `format_{x,y}data`, and rejoin with newlines. return "\n".join( line for line, empty in zip( re.split(",? +", ax.format_coord(*xy)), itertools.chain(["x=", "y=", "z="], itertools.repeat(None))) if line != empty).rstrip() @functools.singledispatch @_call_with_selection def get_ann_text(sel): """ Compute an annotating text for an (unpacked) `Selection`. This is a single-dispatch function; implementations for various artist classes follow. """ warnings.warn( f"Annotation support for {type(sel.artist).__name__} is missing.") return "" def _strip_math(s): return cbook.strip_math(s) if len(s) >= 2 and s[0] == s[-1] == "$" else s def _format_scalarmappable_value(artist, idx): # matplotlib/matplotlib#12473. data = artist.get_array()[idx] if np.ndim(data) == 0: if not artist.colorbar: fig = Figure() ax = fig.subplots() artist.colorbar = fig.colorbar(artist, cax=ax) # This hack updates the ticks without actually paying the cost of # drawing (RendererBase.draw_path raises NotImplementedError). try: ax.yaxis.draw(RendererBase()) except NotImplementedError: pass fmt = artist.colorbar.formatter.format_data_short return "[" + _strip_math(fmt(data).strip()) + "]" else: return artist.format_cursor_data(data) # Includes brackets. @get_ann_text.register(Line2D) @get_ann_text.register(LineCollection) @get_ann_text.register(PatchCollection) @get_ann_text.register(PathCollection) @get_ann_text.register(Patch) @_call_with_selection def _(sel): artist = sel.artist label = artist.get_label() or "" text = _format_coord_unspaced(artist.axes, sel.target) if (_is_scatter(artist) # Heuristic: is the artist colormapped? # Note that this doesn't handle size-mapping (which is more likely # to involve an arbitrary scaling). and artist.get_array() is not None and len(artist.get_array()) == len(artist.get_offsets())): value = _format_scalarmappable_value(artist, sel.index) text = f"{text}\n{value}" if re.match("[^_]", label): text = f"{label}\n{text}" return text _Event = namedtuple("_Event", "xdata ydata") @get_ann_text.register(AxesImage) @_call_with_selection def _(sel): artist = sel.artist text = _format_coord_unspaced(artist.axes, sel.target) cursor_text = _format_scalarmappable_value(artist, sel.index) return f"{text}\n{cursor_text}" @get_ann_text.register(Barbs) @_call_with_selection def _(sel): artist = sel.artist text = "{}\n{}".format( _format_coord_unspaced(artist.axes, sel.target), (artist.u[sel.index], artist.v[sel.index])) return text @get_ann_text.register(Quiver) @_call_with_selection def _(sel): artist = sel.artist text = "{}\n{}".format( _format_coord_unspaced(artist.axes, sel.target), (artist.U[sel.index], artist.V[sel.index])) return text @get_ann_text.register(ContainerArtist) @_call_with_selection def _(sel): return get_ann_text(*sel._replace(artist=sel.artist.container)) @get_ann_text.register(BarContainer) @_call_with_selection(argname="container") def _(sel): return _format_coord_unspaced( _artist_in_container(sel.artist).axes, sel.target) @get_ann_text.register(ErrorbarContainer) @_call_with_selection(argname="container") def _(sel): data_line, cap_lines, err_lcs = sel.artist ann_text = get_ann_text(*sel._replace(artist=data_line)) if isinstance(sel.index, Integral): err_lcs = iter(err_lcs) for idx, (dir, has) in enumerate( zip("xy", [sel.artist.has_xerr, sel.artist.has_yerr])): if has: err = (next(err_lcs).get_paths()[sel.index].vertices - data_line.get_xydata()[sel.index])[:, idx] err_s = [getattr(_artist_in_container(sel.artist).axes, f"format_{dir}data")(e).rstrip() for e in err] # We'd normally want to check err.sum() == 0, but that can run # into fp inaccuracies. signs = "+-\N{MINUS SIGN}" if len({s.lstrip(signs) for s in err_s}) == 1: repl = rf"\1=$\2\\pm{err_s[1]}$\3" else: # Replacing unicode minus by ascii minus don't change the # rendering as the string is mathtext, but allows keeping # the same tests across Matplotlib versions that use # unicode minus and those that don't. err_s = [("+" if not s.startswith(tuple(signs)) else "") + s.replace("\N{MINUS SIGN}", "-") for s in err_s] repl = r"\1=$\2_{%s}^{%s}$\3" % tuple(err_s) ann_text = re.sub(f"({dir})=(.*)(\n?)", repl, ann_text) return ann_text @get_ann_text.register(StemContainer) @_call_with_selection(argname="container") def _(sel): return get_ann_text(*sel._replace(artist=sel.artist.markerline)) @functools.singledispatch @_call_with_selection def move(sel, *, key): """ Move an (unpacked) `Selection` following a keypress. This function is used to implement annotation displacement through the keyboard. This is a single-dispatch function; implementations for various artist classes follow. """ return sel def _move_within_points(sel, xys, *, key): # Avoid infinite loop in case everything became nan at some point. for _ in range(len(xys)): if key == "left": new_idx = int(np.ceil(sel.index) - 1) % len(xys) elif key == "right": new_idx = int(np.floor(sel.index) + 1) % len(xys) else: return sel sel = sel._replace(target_=xys[new_idx], index=new_idx, dist=0) if np.isfinite(sel.target).all(): return sel @move.register(Line2D) @_call_with_selection def _(sel, *, key): data_xy = sel.artist.get_xydata() return _move_within_points( sel, _untransform(data_xy, sel.artist.get_transform().transform(data_xy), sel.artist.axes), key=key) @move.register(PathCollection) @_call_with_selection def _(sel, *, key): if _is_scatter(sel.artist): offsets = sel.artist.get_offsets() return _move_within_points( sel, _untransform( offsets, sel.artist.get_offset_transform().transform(offsets), sel.artist.axes), key=key) else: return sel @move.register(AxesImage) @_call_with_selection def _(sel, *, key): ns = sel.artist.get_array().shape[:2] delta = ( {"left": [0, -1], "right": [0, +1], "down": [-1, 0], "up": [+1, 0]}[ key] * np.array([-1 if sel.artist.axes.yaxis.get_inverted() else +1, -1 if sel.artist.axes.xaxis.get_inverted() else +1])) idxs = (sel.index + delta) % ns xmin, xmax, ymin, ymax = sel.artist.get_extent() if sel.artist.origin == "upper": ymin, ymax = ymax, ymin low, high = np.array([[xmin, ymin], [xmax, ymax]]) target = ((idxs + .5) / ns)[::-1] * (high - low) + low return sel._replace(target_=target, index=tuple(idxs)) @move.register(ContainerArtist) @_call_with_selection def _(sel, *, key): return (move(*sel._replace(artist=sel.artist.container), key=key) ._replace(artist=sel.artist)) @move.register(ErrorbarContainer) @_call_with_selection(argname="container") def _(sel, *, key): data_line, cap_lines, err_lcs = sel.artist return _move_within_points(sel, data_line.get_xydata(), key=key) @functools.singledispatch @_call_with_selection def make_highlight(sel, *, highlight_kwargs): """ Create a highlight for an (unpacked) `Selection`. This is a single-dispatch function; implementations for various artist classes follow. """ warnings.warn( f"Highlight support for {type(sel.artist).__name__} is missing.") def _set_valid_props(artist, kwargs): """Set valid properties for the artist, dropping the others.""" artist.set(**{k: kwargs[k] for k in kwargs if hasattr(artist, "set_" + k)}) return artist @make_highlight.register(Line2D) @_call_with_selection def _(sel, *, highlight_kwargs): hl = copy.copy(sel.artist) _set_valid_props(hl, highlight_kwargs) return hl @make_highlight.register(PathCollection) @_call_with_selection def _(sel, *, highlight_kwargs): hl = copy.copy(sel.artist) offsets = hl.get_offsets() hl.set_offsets(np.where( np.arange(len(offsets))[:, None] == sel.index, offsets, np.nan)) _set_valid_props(hl, highlight_kwargs) return hl mplcursors-0.5.2/setup.cfg000066400000000000000000000004171431464644700155670ustar00rootroot00000000000000[tool:pytest] minversion = 3.7.1 filterwarnings = error ignore::DeprecationWarning error::DeprecationWarning:mplcursors [coverage:run] branch = true source = mplcursors omit = **/_version.py [coverage:paths] source = lib/ /**/python*/site-packages/ mplcursors-0.5.2/setup.py000066400000000000000000000022541431464644700154610ustar00rootroot00000000000000from setupext import find_namespace_packages, setup setup.register_pth_hook("setup_mplcursors_pth.py", "mplcursors.pth") setup( name="mplcursors", description="Interactive data selection cursors for Matplotlib.", long_description=open("README.rst", encoding="utf-8").read(), long_description_content_type="text/x-rst", author="Antony Lee", url="https://github.com/anntzer/mplcursors", license="MIT", classifiers=[ "Development Status :: 4 - Beta", "Framework :: Matplotlib", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", ], packages=find_namespace_packages("lib"), package_dir={"": "lib"}, python_requires=">=3.6", setup_requires=["setuptools_scm"], use_scm_version=lambda: { # xref __init__.py "version_scheme": "post-release", "local_scheme": "node-and-date", "write_to": "lib/mplcursors/_version.py", }, install_requires=[ "matplotlib>=3.1", ], extras_require={ "docs": [ "pandas", "pydata_sphinx_theme!=0.10.1", "sphinx", "sphinx-gallery", ], }, ) mplcursors-0.5.2/setup_mplcursors_pth.py000066400000000000000000000042631431464644700206270ustar00rootroot00000000000000import os if os.environ.get("MPLCURSORS"): # We cannot directly import matplotlib if `MPLCURSORS` is set because # `sys.path` is not correctly set yet. # The loading of `matplotlib.figure` does not go through the path entry # finder because it is a submodule, so we use a metapath finder instead. from importlib.machinery import PathFinder import sys class MplcursorsMetaPathFinder(PathFinder): def find_spec(self, fullname, path=None, target=None): spec = super().find_spec(fullname, path, target) if fullname == "matplotlib.figure": def exec_module(module): type(spec.loader).exec_module(spec.loader, module) # The pth file does not get properly uninstalled from a # develop install. See pypa/pip#4176. try: import mplcursors except ImportError: return import functools import json import weakref # Ensure that when the cursor is removed(), or gets GC'd # because its referents artists are GC'd, the entry also # disappears. cursors = weakref.WeakValueDictionary() options = json.loads(os.environ["MPLCURSORS"]) @functools.wraps(module.Figure.draw) def wrapper(self, *args, **kwargs): rv = wrapper.__wrapped__(self, *args, **kwargs) if self not in cursors: cursor = mplcursors.cursor(self, **options) if cursor.artists: cursors[self] = cursor else: # No artist yet; skip possible init code. cursor.remove() return rv module.Figure.draw = wrapper spec.loader.exec_module = exec_module sys.meta_path.remove(self) return spec sys.meta_path.insert(0, MplcursorsMetaPathFinder()) mplcursors-0.5.2/setupext.py000066400000000000000000000027511431464644700162040ustar00rootroot00000000000000"""setuptools helpers.""" from pathlib import Path import tokenize import setuptools # find_namespace_packages itself bounds support to setuptools>=40.1. from setuptools import Distribution, Extension, find_namespace_packages __all__ = ["Extension", "find_namespace_packages", "setup"] def register_pth_hook(source_path, pth_name): """ :: setup.register_pth_hook("hook_source.py", "hook_name.pth") # Add hook. """ with tokenize.open(source_path) as file: source = file.read() _pth_hook_mixin._pth_hooks.append((pth_name, source)) class _pth_hook_mixin: _pth_hooks = [] def run(self): super().run() for pth_name, source in self._pth_hooks: with Path(self.install_dir, pth_name).open("w") as file: file.write(f"import os; exec({source!r})") def get_outputs(self): return (super().get_outputs() + [str(Path(self.install_dir, pth_name)) for pth_name, _ in self._pth_hooks]) def _prepare_pth_hook(kwargs): cmdclass = kwargs.setdefault("cmdclass", {}) get = Distribution({"cmdclass": cmdclass}).get_command_class cmdclass["develop"] = type( "develop_with_pth_hook", (_pth_hook_mixin, get("develop")), {}) cmdclass["install_lib"] = type( "install_lib_with_pth_hook", (_pth_hook_mixin, get("install_lib")), {}) def setup(**kwargs): _prepare_pth_hook(kwargs) setuptools.setup(**kwargs) setup.register_pth_hook = register_pth_hook mplcursors-0.5.2/tests/000077500000000000000000000000001431464644700151065ustar00rootroot00000000000000mplcursors-0.5.2/tests/__init__.py000066400000000000000000000000001431464644700172050ustar00rootroot00000000000000mplcursors-0.5.2/tests/conftest.py000066400000000000000000000003551431464644700173100ustar00rootroot00000000000000from pathlib import Path def pytest_make_parametrize_id(config, val): if isinstance(val, type(lambda: None)) and val.__qualname__ != "": return val.__qualname__ if isinstance(val, Path): return str(val) mplcursors-0.5.2/tests/test_mplcursors.py000066400000000000000000000625141431464644700207400ustar00rootroot00000000000000import copy import functools import gc import os from pathlib import Path import re import subprocess import sys import weakref import matplotlib as mpl from matplotlib import pyplot as plt from matplotlib.axes import Axes from matplotlib.backend_bases import KeyEvent, MouseEvent import mplcursors from mplcursors import _pick_info, Selection, HoverMode import numpy as np import pytest # The absolute tolerance is quite large to take into account rounding of # LocationEvents to the nearest pixel by Matplotlib, which causes a relative # error of ~ 1/#pixels. approx = functools.partial(pytest.approx, abs=1e-2) @pytest.fixture def fig(): fig = plt.figure(1) fig.canvas.callbacks.exception_handler = None return fig @pytest.fixture def ax(fig): return fig.add_subplot(111) @pytest.fixture(autouse=True) def cleanup(): with mpl.rc_context({"axes.unicode_minus": False}): try: yield finally: mplcursors.__warningregistry__ = {} plt.close("all") def _internal_warnings(record): return [ warning for warning in record if Path(mplcursors.__file__).parent in Path(warning.filename).parents] def _process_event(name, ax, coords, *args): ax.viewLim # unstale viewLim. if name == "__mouse_click__": # So that the dragging callbacks don't go crazy. _process_event("button_press_event", ax, coords, *args) _process_event("button_release_event", ax, coords, *args) return display_coords = ax.transData.transform(coords) if name in ["button_press_event", "button_release_event", "motion_notify_event", "scroll_event"]: event = MouseEvent(name, ax.figure.canvas, *display_coords, *args) elif name in ["key_press_event", "key_release_event"]: event = KeyEvent(name, ax.figure.canvas, *args, *display_coords) else: raise ValueError(f"Unknown event name {name!r}") ax.figure.canvas.callbacks.process(name, event) def _get_remove_args(sel): ax = sel.artist.axes # Text bounds are found only upon drawing. ax.figure.canvas.draw() bbox = sel.annotation.get_window_extent() center = ax.transData.inverted().transform( ((bbox.x0 + bbox.x1) / 2, (bbox.y0 + bbox.y1) / 2)) return "__mouse_click__", ax, center, 3 def _parse_annotation(sel, regex): result = re.fullmatch(regex, sel.annotation.get_text()) assert result, \ "{!r} doesn't match {!r}".format(sel.annotation.get_text(), regex) return tuple(map(float, result.groups())) def test_containerartist(ax): artist = _pick_info.ContainerArtist(ax.errorbar([], [])) str(artist) repr(artist) def test_selection_identity_comparison(): sel0, sel1 = [Selection(artist=None, target_=np.array([0, 0]), index=None, dist=0, annotation=None, extras=[]) for _ in range(2)] assert sel0 != sel1 def test_degenerate_inputs(ax): empty_container = ax.bar([], []) assert not mplcursors.cursor().artists assert not mplcursors.cursor(empty_container).artists pytest.raises(TypeError, mplcursors.cursor, [1]) @pytest.mark.parametrize("plotter", [Axes.plot, Axes.fill]) def test_line(ax, plotter): artist, = plotter(ax, [0, .2, 1], [0, .8, 1], label="foo") cursor = mplcursors.cursor(multiple=True) # Far, far away. _process_event("__mouse_click__", ax, (0, 1), 1) assert len(cursor.selections) == len(ax.figure.artists) == 0 # On the line. _process_event("__mouse_click__", ax, (.1, .4), 1) assert len(cursor.selections) == len(ax.figure.artists) == 1 assert _parse_annotation( cursor.selections[0], "foo\nx=(.*)\ny=(.*)") == approx((.1, .4)) # Not removing it. _process_event("__mouse_click__", ax, (0, 1), 3) assert len(cursor.selections) == len(ax.figure.artists) == 1 # Remove the text label; add another annotation. artist.set_label(None) _process_event("__mouse_click__", ax, (.6, .9), 1) assert len(cursor.selections) == len(ax.figure.artists) == 2 assert _parse_annotation( cursor.selections[1], "x=(.*)\ny=(.*)") == approx((.6, .9)) # Remove both of them (first removing the second one, to test # `Selection.__eq__` -- otherwise it is bypassed as `list.remove` # checks identity first). _process_event(*_get_remove_args(cursor.selections[1])) assert len(cursor.selections) == len(ax.figure.artists) == 1 _process_event(*_get_remove_args(cursor.selections[0])) assert len(cursor.selections) == len(ax.figure.artists) == 0 # Will project on the vertex at (.2, .8). _process_event("__mouse_click__", ax, (.2 - .001, .8 + .001), 1) assert len(cursor.selections) == len(ax.figure.artists) == 1 @pytest.mark.parametrize("plotter", [lambda ax, *args: ax.plot(*args, ls="", marker="o"), Axes.scatter]) def test_scatter(ax, plotter): plotter(ax, [0, .5, 1], [0, .5, 1]) cursor = mplcursors.cursor() _process_event("__mouse_click__", ax, (.2, .2), 1) assert len(cursor.selections) == len(ax.figure.artists) == 0 _process_event("__mouse_click__", ax, (.5, .5), 1) assert len(cursor.selections) == len(ax.figure.artists) == 1 def test_scatter_text(ax): ax.scatter([0, 1], [0, 1], c=[2, 3]) cursor = mplcursors.cursor() _process_event("__mouse_click__", ax, (0, 0), 1) assert _parse_annotation( cursor.selections[0], "x=(.*)\ny=(.*)\n\[(.*)\]") == (0, 0, 2) def test_steps_index(): index = _pick_info.Index(0, .5, .5) assert np.floor(index) == 0 and np.ceil(index) == 1 assert str(index) == "0.(x=0.5, y=0.5)" def test_steps_pre(ax): ax.plot([0, 1], [0, 1], drawstyle="steps-pre") ax.set(xlim=(-1, 2), ylim=(-1, 2)) cursor = mplcursors.cursor() _process_event("__mouse_click__", ax, (1, 0), 1) assert len(cursor.selections) == 0 _process_event("__mouse_click__", ax, (0, .5), 1) index = cursor.selections[0].index assert (index.int, index.x, index.y) == approx((0, 0, .5)) _process_event("__mouse_click__", ax, (.5, 1), 1) index = cursor.selections[0].index assert (index.int, index.x, index.y) == approx((0, .5, 1)) def test_steps_mid(ax): ax.plot([0, 1], [0, 1], drawstyle="steps-mid") ax.set(xlim=(-1, 2), ylim=(-1, 2)) cursor = mplcursors.cursor() _process_event("__mouse_click__", ax, (0, 1), 1) assert len(cursor.selections) == 0 _process_event("__mouse_click__", ax, (1, 0), 1) assert len(cursor.selections) == 0 _process_event("__mouse_click__", ax, (.25, 0), 1) index = cursor.selections[0].index assert (index.int, index.x, index.y) == approx((0, .25, 0)) _process_event("__mouse_click__", ax, (.5, .5), 1) index = cursor.selections[0].index assert (index.int, index.x, index.y) == approx((0, .5, .5)) _process_event("__mouse_click__", ax, (.75, 1), 1) index = cursor.selections[0].index assert (index.int, index.x, index.y) == approx((0, .75, 1)) def test_steps_post(ax): ax.plot([0, 1], [0, 1], drawstyle="steps-post") ax.set(xlim=(-1, 2), ylim=(-1, 2)) cursor = mplcursors.cursor() _process_event("__mouse_click__", ax, (0, 1), 1) assert len(cursor.selections) == 0 _process_event("__mouse_click__", ax, (.5, 0), 1) index = cursor.selections[0].index assert (index.int, index.x, index.y) == approx((0, .5, 0)) _process_event("__mouse_click__", ax, (1, .5), 1) index = cursor.selections[0].index assert (index.int, index.x, index.y) == approx((0, 1, .5)) @pytest.mark.parametrize("ls", ["-", "o"]) def test_line_single_point(ax, ls): ax.plot(0, ls) ax.set(xlim=(-1, 1), ylim=(-1, 1)) cursor = mplcursors.cursor() _process_event("__mouse_click__", ax, (.001, .001), 1) assert len(cursor.selections) == len(ax.figure.artists) == (ls == "o") if cursor.selections: assert tuple(cursor.selections[0].target) == (0, 0) @pytest.mark.parametrize("plot_args,click,targets", [(([0, 1, np.nan, 3, 4],), (.5, .5), [(.5, .5)]), (([np.nan, np.nan],), (0, 0), []), (([np.nan, np.nan], "."), (0, 0), [])]) def test_nan(ax, plot_args, click, targets): ax.plot(*plot_args) cursor = mplcursors.cursor() _process_event("__mouse_click__", ax, click, 1) assert len(cursor.selections) == len(ax.figure.artists) == len(targets) for sel, target in zip(cursor.selections, targets): assert sel.target == approx(target) def test_repeated_point(ax): ax.plot([0, 1, 1, 2], [0, 1, 1, 2]) cursor = mplcursors.cursor() with pytest.warns(None) as record: _process_event("__mouse_click__", ax, (.5, .5), 1) assert not _internal_warnings(record) @pytest.mark.parametrize("origin", ["lower", "upper"]) def test_image(ax, origin): array = np.arange(6).reshape((3, 2)) ax.imshow(array, origin=origin) cursor = mplcursors.cursor() # Annotation text includes image value. _process_event("__mouse_click__", ax, (.25, .25), 1) sel, = cursor.selections assert _parse_annotation( sel, r"x=(.*)\ny=(.*)\n\[0\]") == approx((.25, .25)) # Moving around. _process_event("key_press_event", ax, (.123, .456), "shift+right") sel, = cursor.selections assert _parse_annotation(sel, r"x=(.*)\ny=(.*)\n\[1\]") == (1, 0) assert array[sel.index] == 1 _process_event("key_press_event", ax, (.123, .456), "shift+right") sel, = cursor.selections assert _parse_annotation(sel, r"x=(.*)\ny=(.*)\n\[0\]") == (0, 0) assert array[sel.index] == 0 _process_event("key_press_event", ax, (.123, .456), "shift+up") sel, = cursor.selections assert (_parse_annotation(sel, r"x=(.*)\ny=(.*)\n\[(.*)\]") == {"upper": (0, 2, 4), "lower": (0, 1, 2)}[origin]) assert array[sel.index] == {"upper": 4, "lower": 2}[origin] _process_event("key_press_event", ax, (.123, .456), "shift+down") sel, = cursor.selections assert _parse_annotation(sel, r"x=(.*)\ny=(.*)\n\[0\]") == (0, 0) assert array[sel.index] == 0 cursor = mplcursors.cursor() # Not picking out-of-axes or of image. _process_event("__mouse_click__", ax, (-1, -1), 1) assert len(cursor.selections) == 0 ax.set(xlim=(-1, None), ylim=(-1, None)) _process_event("__mouse_click__", ax, (-.75, -.75), 1) assert len(cursor.selections) == 0 def test_image_rgb(ax): ax.imshow([[[.1, .2, .3], [.4, .5, .6]]]) cursor = mplcursors.cursor() _process_event("__mouse_click__", ax, (0, 0), 1) sel, = cursor.selections assert _parse_annotation( sel, r"x=(.*)\ny=(.*)\n\[0.1, 0.2, 0.3\]") == approx((0, 0)) _process_event("key_press_event", ax, (.123, .456), "shift+right") sel, = cursor.selections assert _parse_annotation( sel, r"x=(.*)\ny=(.*)\n\[0.4, 0.5, 0.6\]") == approx((1, 0)) def test_image_subclass(ax): # Cannot move around `PcolorImage`s. ax.pcolorfast(np.arange(3) ** 2, np.arange(3) ** 2, np.zeros((2, 2))) cursor = mplcursors.cursor() with pytest.warns(UserWarning): _process_event("__mouse_click__", ax, (1, 1), 1) assert len(cursor.selections) == 0 def test_linecollection(ax): ax.eventplot([]) # This must not raise a division by len([]) == 0. ax.eventplot([0, 1]) cursor = mplcursors.cursor() _process_event("__mouse_click__", ax, (0, 0), 1) _process_event("__mouse_click__", ax, (.5, 1), 1) assert len(cursor.selections) == 0 _process_event("__mouse_click__", ax, (0, 1), 1) assert cursor.selections[0].index == approx((0, .5)) def test_patchcollection(ax): ax.add_collection(mpl.collections.PatchCollection([ mpl.patches.Rectangle(xy, .1, .1) for xy in [(0, 0), (.5, .5)]])) cursor = mplcursors.cursor() _process_event("__mouse_click__", ax, (.05, .05), 1) assert len(cursor.selections) == 0 _process_event("__mouse_click__", ax, (.6, .6), 1) # The precision is really bad :( assert cursor.selections[0].index == approx((1, 2), abs=2e-2) @pytest.mark.parametrize("plotter", [Axes.quiver, Axes.barbs]) def test_quiver_and_barbs(ax, plotter): plotter(ax, range(3), range(3)) cursor = mplcursors.cursor() _process_event("__mouse_click__", ax, (.5, 0), 1) assert len(cursor.selections) == 0 _process_event("__mouse_click__", ax, (1, 0), 1) assert _parse_annotation( cursor.selections[0], r"x=(.*)\ny=(.*)\n\(1, 1\)") == (1, 0) @pytest.mark.parametrize("plotter,order", [(Axes.bar, np.s_[:]), (Axes.barh, np.s_[::-1])]) def test_bar(ax, plotter, order): container = plotter(ax, range(3), range(1, 4)) cursor = mplcursors.cursor() assert len(cursor.artists) == 1 _process_event("__mouse_click__", ax, (0, 2)[order], 1) assert len(cursor.selections) == 0 _process_event("__mouse_click__", ax, (0, .5)[order], 1) assert cursor.selections[0].artist is container # not the ContainerArtist. assert cursor.selections[0].target == approx((0, 1)[order]) def test_errorbar(ax): ax.errorbar(range(2), range(2), [(1, 1), (1, 2)]) cursor = mplcursors.cursor() assert len(cursor.artists) == 1 _process_event("__mouse_click__", ax, (0, 2), 1) assert len(cursor.selections) == 0 _process_event("__mouse_click__", ax, (.5, .5), 1) assert cursor.selections[0].target == approx((.5, .5)) assert _parse_annotation( cursor.selections[0], "x=(.*)\ny=(.*)") == approx((.5, .5)) _process_event("__mouse_click__", ax, (0, 1), 1) assert cursor.selections[0].target == approx((0, 0)) assert _parse_annotation( cursor.selections[0], r"x=(.*)\ny=\$(.*)\\pm(.*)\$") == (0, 0, 1) _process_event("__mouse_click__", ax, (1, 2), 1) sel, = cursor.selections assert sel.target == approx((1, 1)) assert _parse_annotation( sel, r"x=(.*)\ny=\$(.*)_\{(.*)\}\^\{(.*)\}\$") == (1, 1, -1, 2) def test_dataless_errorbar(ax): # Unfortunately, the original data cannot be recovered when fmt="none". ax.errorbar(range(2), range(2), [(1, 1), (1, 2)], fmt="none") cursor = mplcursors.cursor() assert len(cursor.artists) == 1 _process_event("__mouse_click__", ax, (0, 0), 1) assert len(cursor.selections) == 0 def test_stem(ax): with pytest.warns(None): # stem use_line_collection API change. ax.stem([1, 2, 3], use_line_collection=True) cursor = mplcursors.cursor() assert len(cursor.artists) == 1 _process_event("__mouse_click__", ax, (.5, .5), 1) assert len(cursor.selections) == 0 _process_event("__mouse_click__", ax, (0, 1), 1) assert cursor.selections[0].target == approx((0, 1)) _process_event("__mouse_click__", ax, (0, .5), 1) assert cursor.selections[0].target == approx((0, 1)) @pytest.mark.parametrize( "plotter,warns", [(lambda ax: ax.text(.5, .5, "foo"), False), (lambda ax: ax.fill_between([0, 1], [0, 1]), True)]) def test_misc_artists(ax, plotter, warns): plotter(ax) cursor = mplcursors.cursor() with pytest.warns(None) as record: _process_event("__mouse_click__", ax, (.5, .5), 1) assert len(cursor.selections) == 0 assert len(_internal_warnings(record)) == warns def test_indexless_projections(fig): ax = fig.subplots(subplot_kw={"projection": "polar"}) ax.plot([1, 2], [3, 4]) cursor = mplcursors.cursor() _process_event("__mouse_click__", ax, (1, 3), 1) assert len(cursor.selections) == 1 _process_event("key_press_event", ax, (.123, .456), "shift+left") def test_cropped_by_axes(fig): axs = fig.subplots(2) axs[0].plot([0, 0], [0, 1]) # Pan to hide the line behind the second axes. axs[0].set(xlim=(-1, 1), ylim=(1, 2)) axs[1].set(xlim=(-1, 1), ylim=(-1, 1)) cursor = mplcursors.cursor() _process_event("__mouse_click__", axs[1], (0, 0), 1) assert len(cursor.selections) == 0 @pytest.mark.parametrize("plotter", [Axes.plot, Axes.scatter, Axes.errorbar]) def test_move(ax, plotter): plotter(ax, [0, 1, 2], [0, 1, np.nan]) cursor = mplcursors.cursor() # Nothing happens with no cursor. _process_event("key_press_event", ax, (.123, .456), "shift+left") assert len(cursor.selections) == 0 # Now we move the cursor left or right. if plotter in [Axes.plot, Axes.errorbar]: _process_event("__mouse_click__", ax, (.5, .5), 1) assert tuple(cursor.selections[0].target) == approx((.5, .5)) _process_event("key_press_event", ax, (.123, .456), "shift+up") _process_event("key_press_event", ax, (.123, .456), "shift+left") elif plotter is Axes.scatter: _process_event("__mouse_click__", ax, (0, 0), 1) _process_event("key_press_event", ax, (.123, .456), "shift+up") assert tuple(cursor.selections[0].target) == (0, 0) assert cursor.selections[0].index == 0 _process_event("key_press_event", ax, (.123, .456), "shift+right") assert tuple(cursor.selections[0].target) == (1, 1) assert cursor.selections[0].index == 1 # Skip through nan. _process_event("key_press_event", ax, (.123, .456), "shift+right") assert tuple(cursor.selections[0].target) == (0, 0) assert cursor.selections[0].index == 0 @pytest.mark.parametrize( "hover", [True, HoverMode.Persistent, 2, HoverMode.Transient]) def test_hover(ax, hover): l1, = ax.plot([0, 1]) l2, = ax.plot([1, 2]) cursor = mplcursors.cursor(hover=hover) _process_event("motion_notify_event", ax, (.5, .5), 1) assert len(cursor.selections) == 0 # No trigger if mouse button pressed. _process_event("motion_notify_event", ax, (.5, .5)) assert cursor.selections[0].artist == l1 _process_event("motion_notify_event", ax, (.5, 1)) assert bool(cursor.selections) == (hover == HoverMode.Persistent) _process_event("motion_notify_event", ax, (.5, 1.5)) assert cursor.selections[0].artist == l2 @pytest.mark.parametrize("plotter", [Axes.plot, Axes.scatter]) def test_highlight(ax, plotter): plotter(ax, [0, 1], [0, 1]) ax.set(xlim=(-1, 2), ylim=(-1, 2)) base_children = {*ax.artists, *ax.lines, *ax.collections} cursor = mplcursors.cursor(highlight=True) _process_event("__mouse_click__", ax, (0, 0), 1) # On Matplotlib<=3.4, the highlight went to ax.artists. On >=3.5, it goes # to its type-specific container. The construct below handles both cases. assert [*{*ax.artists, *ax.lines, *ax.collections} - base_children] \ == cursor.selections[0].extras != [] _process_event(*_get_remove_args(cursor.selections[0])) assert len({*ax.artists, *ax.lines, *ax.collections} - base_children) == 0 def test_misc_artists_highlight(ax): # Unsupported artists trigger a warning upon a highlighting attempt. ax.imshow([[0, 1], [2, 3]]) cursor = mplcursors.cursor(highlight=True) with pytest.warns(UserWarning): _process_event("__mouse_click__", ax, (.5, .5), 1) def test_callback(ax): ax.plot([0, 1]) calls = [] cursor = mplcursors.cursor() @cursor.connect("add") def on_add(sel): calls.append(sel) _process_event("__mouse_click__", ax, (.5, .5), 1) assert len(calls) == 1 cursor.disconnect("add", on_add) _process_event("__mouse_click__", ax, (.5, .5), 1) assert len(calls) == 1 def test_remove_while_adding(ax): ax.plot([0, 1]) cursor = mplcursors.cursor() cursor.connect("add", cursor.remove_selection) _process_event("__mouse_click__", ax, (.5, .5), 1) def test_no_duplicate(ax): ax.plot([0, 1]) cursor = mplcursors.cursor(multiple=True) _process_event("__mouse_click__", ax, (.5, .5), 1) _process_event("__mouse_click__", ax, (.5, .5), 1) assert len(cursor.selections) == 1 def test_remove_multiple_overlapping(ax): ax.plot([0, 1]) cursor = mplcursors.cursor(multiple=True) _process_event("__mouse_click__", ax, (.5, .5), 1) sel, = cursor.selections cursor.add_selection(copy.copy(sel)) assert len(cursor.selections) == 2 _process_event(*_get_remove_args(sel)) assert [*map(id, cursor.selections)] == [id(sel)] # To check LIFOness. _process_event(*_get_remove_args(sel)) assert len(cursor.selections) == 0 def test_autoalign(ax): ax.plot([0, 1]) cursor = mplcursors.cursor() cursor.connect( "add", lambda sel: sel.annotation.set(position=(-10, 0))) _process_event("__mouse_click__", ax, (.5, .5), 1) sel, = cursor.selections assert (sel.annotation.get_ha() == "right" and sel.annotation.get_va() == "center") cursor.remove_selection(sel) cursor.connect( "add", lambda sel: sel.annotation.set(ha="center", va="bottom")) _process_event("__mouse_click__", ax, (.5, .5), 1) sel, = cursor.selections assert (sel.annotation.get_ha() == "center" and sel.annotation.get_va() == "bottom") @pytest.mark.xfail( int(mpl.__version__.split(".")[0]) < 3, reason="Matplotlib fails to disconnect dragging callbacks.") def test_drag(ax, capsys): l, = ax.plot([0, 1]) cursor = mplcursors.cursor() cursor.connect( "add", lambda sel: sel.annotation.set(position=(0, 0))) _process_event("__mouse_click__", ax, (.5, .5), 1) _process_event("button_press_event", ax, (.5, .5), 1) _process_event("motion_notify_event", ax, (.4, .6), 1) assert not capsys.readouterr().err def test_removed_artist(ax): l, = ax.plot([0, 1]) cursor = mplcursors.cursor() l.remove() _process_event("__mouse_click__", ax, (.5, .5), 1) assert len(cursor.selections) == len(ax.figure.artists) == 0 def test_remove_cursor(ax): ax.plot([0, 1]) cursor = mplcursors.cursor() _process_event("__mouse_click__", ax, (.5, .5), 1) assert len(cursor.selections) == len(ax.figure.artists) == 1 cursor.remove() assert len(cursor.selections) == len(ax.figure.artists) == 0 _process_event("__mouse_click__", ax, (.5, .5), 1) assert len(cursor.selections) == len(ax.figure.artists) == 0 def test_keys(ax): ax.plot([0, 1]) cursor = mplcursors.cursor(multiple=True) _process_event("__mouse_click__", ax, (.3, .3), 1) # Toggle visibility. _process_event("key_press_event", ax, (.123, .456), "v") assert not cursor.selections[0].annotation.get_visible() _process_event("key_press_event", ax, (.123, .456), "v") assert cursor.selections[0].annotation.get_visible() # Disable the cursor. _process_event("key_press_event", ax, (.123, .456), "e") assert not cursor.enabled # (Adding becomes inactive.) _process_event("__mouse_click__", ax, (.6, .6), 1) assert len(cursor.selections) == 1 # (Removing becomes inactive.) ax.figure.canvas.draw() _process_event(*_get_remove_args(cursor.selections[0])) assert len(cursor.selections) == 1 # (Moving becomes inactive.) old_target = cursor.selections[0].target _process_event("key_press_event", ax, (.123, .456), "shift+left") new_target = cursor.selections[0].target assert (old_target == new_target).all() # Reenable it. _process_event("key_press_event", ax, (.123, .456), "e") assert cursor.enabled _process_event(*_get_remove_args(cursor.selections[0])) assert len(cursor.selections) == 0 def test_convenience(ax): l, = ax.plot([1, 2]) assert len(mplcursors.cursor().artists) == 1 assert len(mplcursors.cursor(ax).artists) == 1 assert len(mplcursors.cursor(l).artists) == 1 assert len(mplcursors.cursor([l]).artists) == 1 bc = ax.bar(range(3), range(3)) assert len(mplcursors.cursor(bc).artists) == 1 def test_invalid_args(): pytest.raises(ValueError, mplcursors.cursor, bindings={"foo": 42}) pytest.raises(ValueError, mplcursors.cursor, bindings={"select": 1, "deselect": 1}) pytest.raises(ValueError, mplcursors.cursor().connect, "foo") def test_multiple_figures(ax): ax1 = ax _, ax2 = plt.subplots() ax1.plot([0, 1]) ax2.plot([0, 1]) cursor = mplcursors.cursor([ax1, ax2], multiple=True) # Add something on the first axes. _process_event("__mouse_click__", ax1, (.5, .5), 1) assert len(cursor.selections) == 1 assert len(ax1.figure.artists) == 1 assert len(ax2.figure.artists) == 0 # Right-clicking on the second axis doesn't remove it. remove_args = [*_get_remove_args(cursor.selections[0])] remove_args[remove_args.index(ax1)] = ax2 _process_event(*remove_args) assert len(cursor.selections) == 1 assert len(ax1.figure.artists) == 1 assert len(ax2.figure.artists) == 0 # Remove it, add something on the second. _process_event(*_get_remove_args(cursor.selections[0])) _process_event("__mouse_click__", ax2, (.5, .5), 1) assert len(cursor.selections) == 1 assert len(ax1.figure.artists) == 0 assert len(ax2.figure.artists) == 1 def test_gc(ax): def inner(): img = ax.imshow([[0, 1], [2, 3]]) cursor = mplcursors.cursor(img) f_img = weakref.finalize(img, lambda: None) f_cursor = weakref.finalize(cursor, lambda: None) img.remove() return f_img, f_cursor f_img, f_cursor = inner() gc.collect() assert not f_img.alive assert not f_cursor.alive @pytest.mark.parametrize( "example", [path for path in Path("examples").glob("*.py") if "test: skip" not in path.read_text()]) def test_example(example): subprocess.check_call( [sys.executable, "-mexamples.{}".format(example.with_suffix("").name)], # Unset $DISPLAY to avoid the non-GUI backend warning. env={**os.environ, "DISPLAY": "", "MPLBACKEND": "Agg"})